Move secrets between users and computers without the model seeing them
A new personal env scope (paradigm user: one user's secret for one bot) beats every other scope, reaches only that user's runs of that bot, and is redacted like any secret. Bots take secrets in with request_secret, which puts a [secret-request:ID] card in the chat: the user types the value into a masked input there, never into a message, and the bot is told only the key. Bots show secrets with capture_secret (a task's computer tool that saves a file or a command's output straight to a personal env, then redacts it from the rest of the run) and show_secret (an env by key): both give a [secret:ID] chip whose Reveal and Copy fetch the value on click, for its own user (personal) or a super admin (anything else), logged by key. Values never go into messages, page props, history, Copy chat or logs. Users manage their own secrets on Settings > Secrets; the Environment page lists everyone's, remove only. Deleting a bot or user removes its personal secrets, and SecretsNote tells bots never to ask for or print secrets in chat. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -94,7 +94,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
|
|||||||
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
|
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
|
||||||
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
|
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
|
||||||
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
|
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
|
||||||
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`. Parsers in `src/utils/*-ref.ts`.
|
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card). Parsers in `src/utils/*-ref.ts`.
|
||||||
- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch.
|
- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch.
|
||||||
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
|
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
|
||||||
- **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet.
|
- **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet.
|
||||||
@@ -151,7 +151,9 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
|
|||||||
|
|
||||||
### Envs
|
### Envs
|
||||||
|
|
||||||
Set in Settings > Environment (super admin), not `.env`. `envs` (private; encrypted `value`; `paradigm` general|workspace|computer|bot; one per key and scope, enforced in `saveEnv`). Cached per process. App settings (`EnvDict` in `src/dict/env-dict.ts`) via `grabEnv` (general value, else `process.env`); `ProcessOnlyEnvKeys` can't be saved. Precedence for runs: bot > computer > workspace > general (`resolveScopedEnvs`). Written to `~/.cobble/env` and `env.json` on the computer each run; bots see keys and descriptions, never values; every computer tool result goes through `redactSecrets`.
|
Set in Settings > Environment (super admin), not `.env`. `envs` (private; encrypted `value`; `paradigm` general|workspace|computer|bot|user; one per key and scope, enforced in `saveEnv`). Cached per process. App settings (`EnvDict` in `src/dict/env-dict.ts`) via `grabEnv` (general value, else `process.env`); `ProcessOnlyEnvKeys` can't be saved. Precedence for runs: user > bot > computer > workspace > general (`resolveScopedEnvs`, `grabRunEnvs`; pass the run's `user_id`). Written to `~/.cobble/env` and `env.json` on the computer each run; bots see keys and descriptions, never values; every computer tool result goes through `redactSecrets`.
|
||||||
|
|
||||||
|
**Secrets never pass through the model.** A `user` env is one user's secret for one bot (`saveUserSecret`, reach-checked), set from the chat, listed on Settings > Secrets (own) and Environment (everyone's, remove only). In: `request_secret` -> `secret_requests` -> `[secret-request:ID]` card (masked input, `answer-request`), then a chat message naming only the key. Out: `capture_secret` (task computer tool: a file via `/v1/file/download` or a command's output, saved straight to a `user` env and added to the run's redaction list) and `show_secret` (an env by key) give `[secret:ID]`; the chip fetches the value only on click (`api/admin/secrets/reveal-secret`, `canRevealEnv`: a personal one only its own user, anything else only a super admin). Never put a value in a message, page prop, log or tool result. `SecretsNote` tells bots never to ask for secrets in chat.
|
||||||
|
|
||||||
### PWA and notifications
|
### PWA and notifications
|
||||||
|
|
||||||
@@ -207,7 +209,7 @@ Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspa
|
|||||||
`AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout.
|
`AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout.
|
||||||
|
|
||||||
- **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside.
|
- **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside.
|
||||||
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `profile`, `notifications`, `change-password`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills` (crew managers), `users` (`canListUsers`).
|
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills` (crew managers), `users` (`canListUsers`).
|
||||||
- Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`).
|
- Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`).
|
||||||
- **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process.
|
- **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process.
|
||||||
- **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`.
|
- **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`.
|
||||||
|
|||||||
+77
-3
@@ -33,7 +33,7 @@ import {
|
|||||||
BotFileSources,
|
BotFileSources,
|
||||||
} from "@/src/dict/bot-file-dict";
|
} from "@/src/dict/bot-file-dict";
|
||||||
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
||||||
import { EnvParadigms } from "@/src/dict/env-dict";
|
import { EnvParadigms, SecretRequestStatuses } from "@/src/dict/env-dict";
|
||||||
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
|
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
|
||||||
import {
|
import {
|
||||||
CustomToolKinds,
|
CustomToolKinds,
|
||||||
@@ -1431,6 +1431,68 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "secret_requests",
|
||||||
|
tableDescription: `A secret (a password, token or key) a bot asked a user for in their conversation. The user types it into the request's card, never into the chat, so the model never sees it: it's saved as their personal env (paradigm user) for that bot, which reaches that user's runs on computers by key`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "key",
|
||||||
|
fieldDescription: `The env key it's saved under`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "description",
|
||||||
|
fieldDescription: `What it is, saved as the env's description`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "reason",
|
||||||
|
fieldDescription: `Why the bot asks, in its own words, shown on the card as such`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "status",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: [...SecretRequestStatuses],
|
||||||
|
defaultValue: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "env_id",
|
||||||
|
fieldDescription: `The env it saved, once saved. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "decided_at",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `The user it was asked of, who alone answers it. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
fieldDescription: `The bot that asked, whose runs for that user get it. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The conversation it was asked in. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The session's workspace; null for the bot's own chat. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_secret_requests_session_id",
|
||||||
|
indexTableFields: ["session_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "workspaces",
|
tableName: "workspaces",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -3086,7 +3148,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "paradigm",
|
fieldName: "paradigm",
|
||||||
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`,
|
fieldDescription: `Where it applies: general, a workspace, a computer, a single bot, or one user's runs of one bot (user, set from the chat). A user's own beats the bot's, which 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",
|
||||||
@@ -3117,7 +3179,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "bot_id",
|
fieldName: "bot_id",
|
||||||
fieldDescription: `The bot, for paradigm bot. Goes with the bot`,
|
fieldDescription: `The bot, for paradigm bot or user. Goes with the bot`,
|
||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
cascadeDelete: true,
|
cascadeDelete: true,
|
||||||
@@ -3127,6 +3189,18 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
foreignKeyName: "envs_bot_fk",
|
foreignKeyName: "envs_bot_fk",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `The user, for paradigm user: only their runs of \`bot_id\` get it, and only they may reveal it. Goes with the user`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "users",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "envs_user_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`,
|
||||||
|
|||||||
+58
-4
@@ -20,6 +20,7 @@ export const BunSQLiteTables = [
|
|||||||
"bot_hooks",
|
"bot_hooks",
|
||||||
"bot_hook_events",
|
"bot_hook_events",
|
||||||
"hook_proposals",
|
"hook_proposals",
|
||||||
|
"secret_requests",
|
||||||
"workspaces",
|
"workspaces",
|
||||||
"workspace_bots",
|
"workspace_bots",
|
||||||
"bot_types",
|
"bot_types",
|
||||||
@@ -896,6 +897,55 @@ export type BUN_SQLITE_COBBLE_HOOK_PROPOSALS = {
|
|||||||
workspace_id?: number | "";
|
workspace_id?: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_SECRET_REQUESTS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The env key it's saved under
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
/**
|
||||||
|
* What it is, saved as the env's description
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Why the bot asks, in its own words, shown on the card as such
|
||||||
|
*/
|
||||||
|
reason?: string;
|
||||||
|
status?: "pending" | "saved" | "declined" | "";
|
||||||
|
/**
|
||||||
|
* The env it saved, once saved. Not a foreign key
|
||||||
|
*/
|
||||||
|
env_id?: number | "";
|
||||||
|
decided_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The user it was asked of, who alone answers it. Not a foreign key
|
||||||
|
*/
|
||||||
|
user_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot that asked, whose runs for that user get it. Not a foreign key
|
||||||
|
*/
|
||||||
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The conversation it was asked in. Not a foreign key
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session's workspace; null for the bot's own chat. Not a foreign key
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_WORKSPACES = {
|
export type BUN_SQLITE_COBBLE_WORKSPACES = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1895,9 +1945,9 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
|||||||
*/
|
*/
|
||||||
description?: string;
|
description?: string;
|
||||||
/**
|
/**
|
||||||
* 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
|
* Where it applies: general, a workspace, a computer, a single bot, or one user's runs of one bot (user, set from the chat). A user's own beats the bot's, which beats the computer, which beats the workspace, which beats general
|
||||||
*/
|
*/
|
||||||
paradigm?: "general" | "workspace" | "computer" | "bot" | "";
|
paradigm?: "general" | "workspace" | "computer" | "bot" | "user" | "";
|
||||||
/**
|
/**
|
||||||
* The workspace, for paradigm workspace
|
* The workspace, for paradigm workspace
|
||||||
*/
|
*/
|
||||||
@@ -1907,9 +1957,13 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
|||||||
*/
|
*/
|
||||||
computer_id?: number | "";
|
computer_id?: number | "";
|
||||||
/**
|
/**
|
||||||
* The bot, for paradigm bot. Goes with the bot
|
* The bot, for paradigm bot or user. Goes with the bot
|
||||||
*/
|
*/
|
||||||
bot_id?: number | "";
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The user, for paradigm user: only their runs of `bot_id` get it, and only they may reveal it. Goes with the user
|
||||||
|
*/
|
||||||
|
user_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
|
||||||
*/
|
*/
|
||||||
@@ -2167,4 +2221,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
|
||||||
@@ -3,6 +3,7 @@ import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
|
SecretsNote,
|
||||||
botFilesNote,
|
botFilesNote,
|
||||||
chatReplyNote,
|
chatReplyNote,
|
||||||
ComputerFilesNote,
|
ComputerFilesNote,
|
||||||
@@ -38,6 +39,7 @@ import hasUnansweredMessage from "./has-unanswered-message";
|
|||||||
import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id";
|
import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-answered-message-id";
|
||||||
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
|
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
|
||||||
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
|
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
|
||||||
|
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
|
||||||
import type { BotMessageWithAttachments } from "@/src/types";
|
import type { BotMessageWithAttachments } from "@/src/types";
|
||||||
import type { HarnessMessage } from "@/src/harness/types";
|
import type { HarnessMessage } from "@/src/harness/types";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
@@ -144,6 +146,11 @@ export default async function runBotReply({
|
|||||||
* coordinator did: their cards need the user just like a take-over.
|
* coordinator did: their cards need the user just like a take-over.
|
||||||
*/
|
*/
|
||||||
const proposals: string[] = [];
|
const proposals: string[] = [];
|
||||||
|
/**
|
||||||
|
* The secrets the bot showed (`show_secret`), whose chips the reply
|
||||||
|
* always carries.
|
||||||
|
*/
|
||||||
|
const shown: string[] = [];
|
||||||
/**
|
/**
|
||||||
* Files the bot wrote and asked to send with its reply.
|
* Files the bot wrote and asked to send with its reply.
|
||||||
*/
|
*/
|
||||||
@@ -194,6 +201,7 @@ export default async function runBotReply({
|
|||||||
|
|
||||||
const extensions = await openBotExtensions({
|
const extensions = await openBotExtensions({
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id: computers[0]?.id || null,
|
computer_id: computers[0]?.id || null,
|
||||||
dirs,
|
dirs,
|
||||||
@@ -236,6 +244,7 @@ export default async function runBotReply({
|
|||||||
TasksNote,
|
TasksNote,
|
||||||
TaskReminderNote,
|
TaskReminderNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
|
SecretsNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
composeComputersNote({
|
composeComputersNote({
|
||||||
computers,
|
computers,
|
||||||
@@ -253,6 +262,15 @@ export default async function runBotReply({
|
|||||||
tools: [
|
tools: [
|
||||||
...tools,
|
...tools,
|
||||||
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
|
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
|
||||||
|
...grabBotSecretTools({
|
||||||
|
user_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id: computers[0]?.id || null,
|
||||||
|
onRequest: ({ ref }) => proposals.push(ref),
|
||||||
|
onShow: ({ ref }) => shown.push(ref),
|
||||||
|
}),
|
||||||
...file_tools,
|
...file_tools,
|
||||||
...extensions.tools,
|
...extensions.tools,
|
||||||
...skills.tools,
|
...skills.tools,
|
||||||
@@ -273,6 +291,7 @@ export default async function runBotReply({
|
|||||||
...withTakeOverRefs({
|
...withTakeOverRefs({
|
||||||
text: stripAppRecords({ text: result.text }),
|
text: stripAppRecords({ text: result.text }),
|
||||||
refs: [...take_overs, ...proposals],
|
refs: [...take_overs, ...proposals],
|
||||||
|
shown,
|
||||||
}),
|
}),
|
||||||
attachments: attachments.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
tool_log: result.tool_uses,
|
tool_log: result.tool_uses,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import workspaceAdminsNote from "./workspace-admins-note";
|
|||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import {
|
import {
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
|
SecretsNote,
|
||||||
botFilesNote,
|
botFilesNote,
|
||||||
ComputerFilesNote,
|
ComputerFilesNote,
|
||||||
ScheduledRunNote,
|
ScheduledRunNote,
|
||||||
@@ -22,6 +23,7 @@ import openComputerTools from "@/src/harness/tools/computer/open-computer-tools"
|
|||||||
import openBotExtensions, { type OpenBotExtensions } from "@/src/harness/tools/extensions/open-bot-extensions";
|
import openBotExtensions, { type OpenBotExtensions } from "@/src/harness/tools/extensions/open-bot-extensions";
|
||||||
import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/open-bot-skills";
|
import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/open-bot-skills";
|
||||||
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
|
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
|
||||||
|
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
|
||||||
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
|
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
|
||||||
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
||||||
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
|
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
|
||||||
@@ -75,12 +77,20 @@ export default async function runBotTask({
|
|||||||
*/
|
*/
|
||||||
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
|
const dirs = computerRunDirs({ bot_id: bot.id, bot_kind: bot.kind, user_id, workspace_id });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The secrets the bot captured or showed, whose chips the report
|
||||||
|
* always carries.
|
||||||
|
*/
|
||||||
|
const shown: string[] = [];
|
||||||
|
|
||||||
const computer = job.computer_id
|
const computer = job.computer_id
|
||||||
? await openComputerTools({
|
? await openComputerTools({
|
||||||
computer_id: job.computer_id,
|
computer_id: job.computer_id,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
dirs,
|
dirs,
|
||||||
|
onSecretRef: ({ ref }) => shown.push(ref),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -175,7 +185,7 @@ export default async function runBotTask({
|
|||||||
: []),
|
: []),
|
||||||
...(computers.length
|
...(computers.length
|
||||||
? [
|
? [
|
||||||
searchComputersTool({ bot_id: bot.id, computers, workspace_id, dirs }),
|
searchComputersTool({ bot_id: bot.id, user_id, computers, workspace_id, dirs }),
|
||||||
askToTakeOverTool({
|
askToTakeOverTool({
|
||||||
computers: take_over_computers,
|
computers: take_over_computers,
|
||||||
in_task: true,
|
in_task: true,
|
||||||
@@ -183,6 +193,16 @@ export default async function runBotTask({
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...grabBotSecretTools({
|
||||||
|
user_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id: job.computer_id || null,
|
||||||
|
in_task: true,
|
||||||
|
onRequest: hooked ? undefined : ({ ref }) => take_overs.push(ref),
|
||||||
|
onShow: ({ ref }) => shown.push(ref),
|
||||||
|
}),
|
||||||
...(scheduled || hooked ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
|
...(scheduled || hooked ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
|
||||||
...(computer?.tools || []),
|
...(computer?.tools || []),
|
||||||
...(job.computer_id
|
...(job.computer_id
|
||||||
@@ -208,6 +228,7 @@ export default async function runBotTask({
|
|||||||
|
|
||||||
extensions = await openBotExtensions({
|
extensions = await openBotExtensions({
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id: job.computer_id || null,
|
computer_id: job.computer_id || null,
|
||||||
dirs,
|
dirs,
|
||||||
@@ -232,6 +253,7 @@ export default async function runBotTask({
|
|||||||
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
|
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
|
||||||
TasksNote,
|
TasksNote,
|
||||||
ActionCheckNote,
|
ActionCheckNote,
|
||||||
|
SecretsNote,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
powers.is_coordinator
|
powers.is_coordinator
|
||||||
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
|
? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.`
|
||||||
@@ -288,6 +310,7 @@ export default async function runBotTask({
|
|||||||
...withTakeOverRefs({
|
...withTakeOverRefs({
|
||||||
text: computer ? computer.redact(text) : text,
|
text: computer ? computer.redact(text) : text,
|
||||||
refs: take_overs,
|
refs: take_overs,
|
||||||
|
shown,
|
||||||
}),
|
}),
|
||||||
attachments: attachments.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
tool_log: result.tool_uses,
|
tool_log: result.tool_uses,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ type Params = {
|
|||||||
* and the proposals it made for the user to confirm (`proposalRef`).
|
* and the proposals it made for the user to confirm (`proposalRef`).
|
||||||
*/
|
*/
|
||||||
refs: string[];
|
refs: string[];
|
||||||
|
/**
|
||||||
|
* The secrets it showed (`secretRef`): added the same way, but they
|
||||||
|
* don't need the user.
|
||||||
|
*/
|
||||||
|
shown?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,8 +18,8 @@ type Params = {
|
|||||||
* end, so the user always gets the button. A message asking for either
|
* end, so the user always gets the button. A message asking for either
|
||||||
* needs the user.
|
* needs the user.
|
||||||
*/
|
*/
|
||||||
export default function withTakeOverRefs({ text, refs }: Params) {
|
export default function withTakeOverRefs({ text, refs, shown = [] }: Params) {
|
||||||
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
|
const missing = [...new Set([...refs, ...shown])].filter((ref) => !text.includes(ref));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: [text.trim(), ...missing].filter(Boolean).join("\n\n"),
|
result: [text.trim(), ...missing].filter(Boolean).join("\n\n"),
|
||||||
|
|||||||
@@ -366,6 +366,13 @@ export const AppData = {
|
|||||||
*/
|
*/
|
||||||
ComputerSearchTimeoutMs: 30 * 1000,
|
ComputerSearchTimeoutMs: 30 * 1000,
|
||||||
ComputerSearchMaxOutputChars: 4000,
|
ComputerSearchMaxOutputChars: 4000,
|
||||||
|
/**
|
||||||
|
* `capture_secret`: the longest value it saves (a file or a command's
|
||||||
|
* output; longer is refused, never cut), and how long the command may
|
||||||
|
* run.
|
||||||
|
*/
|
||||||
|
CapturedSecretMaxChars: 8000,
|
||||||
|
CaptureSecretTimeoutMs: 30 * 1000,
|
||||||
/**
|
/**
|
||||||
* How many of a job's latest steps (`bots_queue_updates`) the
|
* How many of a job's latest steps (`bots_queue_updates`) the
|
||||||
* working-on panel shows.
|
* working-on panel shows.
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ Every action counts here: saving memory or instructions, creating or changing a
|
|||||||
- Never deny something you did either. When the user says a change isn't where they looked, check what actually happened before you answer: the app records and tool results in this conversation, and what you saved. Then say what you did and where it went, and if it went somewhere other than where they looked, point them to it. Never take the blame for work that happened, and never write a false account of it into your memory or instructions; if you already wrote one, correct it with update_memory.
|
- Never deny something you did either. When the user says a change isn't where they looked, check what actually happened before you answer: the app records and tool results in this conversation, and what you saved. Then say what you did and where it went, and if it went somewhere other than where they looked, point them to it. Never take the blame for work that happened, and never write a false account of it into your memory or instructions; if you already wrote one, correct it with update_memory.
|
||||||
- Starting or handing over a task isn't finishing it: say it's under way, not done.`;
|
- Starting or handing over a task isn't finishing it: say it's under way, not done.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to every reply and task: secrets move through the app, never
|
||||||
|
* through the model.
|
||||||
|
*/
|
||||||
|
export const SecretsNote = `# Secrets
|
||||||
|
|
||||||
|
Anything you read or write goes to your model provider, so secret values (passwords, tokens, API keys, private keys) must never pass through you.
|
||||||
|
|
||||||
|
- Never ask the user to type or paste a secret into the chat. Use request_secret: they type it into a masked card, and it reaches your computers as $KEY.
|
||||||
|
- If they paste one anyway, don't repeat it; tell them to remove it from the message and use the card you send with request_secret instead.
|
||||||
|
- Never print, cat or echo a secret on a computer. To show one to the user, use show_secret for an env you have, or in a task on a computer capture it with capture_secret: either gives you a [secret:ID] ref to put in your message.
|
||||||
|
- Use secrets by key ($KEY in the shell, {{env.KEY}} in tools), never by value. A value you see as [KEY] in output was redacted: that's expected.`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Added to the coordinator's chat prompt: it hands work to the crew.
|
* Added to the coordinator's chat prompt: it hands work to the crew.
|
||||||
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
* `bot_id` is the coordinator itself, so the ref example it copies is its
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export const JobStepToolLabels: Record<string, string> = {
|
|||||||
delegate_task: "Handing work to the crew",
|
delegate_task: "Handing work to the crew",
|
||||||
hand_over_schedule: "Handing a routine to the crew",
|
hand_over_schedule: "Handing a routine to the crew",
|
||||||
propose_hook: "Proposing a hook",
|
propose_hook: "Proposing a hook",
|
||||||
|
request_secret: "Asking for a secret",
|
||||||
|
capture_secret: "Saving a secret",
|
||||||
|
show_secret: "Showing a secret",
|
||||||
pause_hook: "Pausing a hook",
|
pause_hook: "Pausing a hook",
|
||||||
delete_hook: "Deleting a hook",
|
delete_hook: "Deleting a hook",
|
||||||
assign_computer: "Changing computer access",
|
assign_computer: "Changing computer access",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const ReservedToolNames = [
|
|||||||
"add_bot_to_workspace",
|
"add_bot_to_workspace",
|
||||||
"ask_user_to_take_over",
|
"ask_user_to_take_over",
|
||||||
"assign_computer",
|
"assign_computer",
|
||||||
|
"capture_secret",
|
||||||
"create_bot",
|
"create_bot",
|
||||||
"create_schedule",
|
"create_schedule",
|
||||||
"create_workspace",
|
"create_workspace",
|
||||||
@@ -77,12 +78,14 @@ export const ReservedToolNames = [
|
|||||||
"propose_mcp_server",
|
"propose_mcp_server",
|
||||||
"propose_skill",
|
"propose_skill",
|
||||||
"report_run_failed",
|
"report_run_failed",
|
||||||
|
"request_secret",
|
||||||
"search_computers",
|
"search_computers",
|
||||||
"send_message",
|
"send_message",
|
||||||
"set_bot_workspace_instructions",
|
"set_bot_workspace_instructions",
|
||||||
"set_extension_access",
|
"set_extension_access",
|
||||||
"set_picture",
|
"set_picture",
|
||||||
"set_workspace_computer",
|
"set_workspace_computer",
|
||||||
|
"show_secret",
|
||||||
"start_task",
|
"start_task",
|
||||||
"update_custom_tool",
|
"update_custom_tool",
|
||||||
"update_instructions",
|
"update_instructions",
|
||||||
|
|||||||
+15
-2
@@ -1,8 +1,9 @@
|
|||||||
import { HarnessProviderKinds } from "./harness-provider-dict";
|
import { HarnessProviderKinds } from "./harness-provider-dict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where an env applies. A bot's own env beats the computer it runs on,
|
* Where an env applies. A user's personal one (for one bot, set from the
|
||||||
* which beats the workspace it works in, which beats a general one.
|
* chat) beats the bot's own, which beats the computer it runs on, which
|
||||||
|
* beats the workspace it works in, which beats a general one.
|
||||||
*/
|
*/
|
||||||
export const EnvParadigms = [
|
export const EnvParadigms = [
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,10 @@ export const EnvParadigms = [
|
|||||||
title: "Bot",
|
title: "Bot",
|
||||||
value: "bot",
|
value: "bot",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Personal",
|
||||||
|
value: "user",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EnvParadigm = (typeof EnvParadigms)[number]["value"];
|
export type EnvParadigm = (typeof EnvParadigms)[number]["value"];
|
||||||
@@ -109,3 +114,11 @@ export const ProcessOnlyEnvKeys = [
|
|||||||
* What an env key may look like: a shell variable name.
|
* What an env key may look like: a shell variable name.
|
||||||
*/
|
*/
|
||||||
export const EnvKeyRegex = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
export const EnvKeyRegex = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a secret a bot asked a user for stands (`secret_requests.status`):
|
||||||
|
* `pending` until the user saves a value (`saved`) or declines it.
|
||||||
|
*/
|
||||||
|
export const SecretRequestStatuses = ["pending", "saved", "declined"] as const;
|
||||||
|
|
||||||
|
export type SecretRequestStatus = (typeof SecretRequestStatuses)[number];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DatabaseBackup,
|
DatabaseBackup,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Layers,
|
Layers,
|
||||||
|
LockKeyhole,
|
||||||
Monitor,
|
Monitor,
|
||||||
Plug,
|
Plug,
|
||||||
Users,
|
Users,
|
||||||
@@ -53,6 +54,7 @@ export const SettingsPages: SettingsPage[] = [
|
|||||||
url: "/admin/settings/change-password",
|
url: "/admin/settings/change-password",
|
||||||
Icon: KeyRound,
|
Icon: KeyRound,
|
||||||
},
|
},
|
||||||
|
{ title: "Secrets", url: "/admin/settings/secrets", Icon: LockKeyhole },
|
||||||
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot, can_view: canManageCrew },
|
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot, can_view: canManageCrew },
|
||||||
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, can_view: canManageCrew },
|
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, can_view: canManageCrew },
|
||||||
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
|
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
|||||||
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
||||||
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
|
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import { clearEnvCache } from "../envs/grab-all-envs";
|
||||||
import grabUserBot from "./grab-user-bot";
|
import grabUserBot from "./grab-user-bot";
|
||||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ type Params = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes one of the user's worker bots with its conversations, tasks,
|
* Deletes one of the user's worker bots with its conversations, tasks,
|
||||||
* jobs, models and workspace places. A job it's running stops: it loses
|
* jobs, models, envs (users' personal secrets for it too) and workspace
|
||||||
|
* places. A job it's running stops: it loses
|
||||||
* its row. The coordinator can't be deleted.
|
* its row. The coordinator can't be deleted.
|
||||||
*/
|
*/
|
||||||
export default async function deleteBot({ user_id, bot_id }: Params) {
|
export default async function deleteBot({ user_id, bot_id }: Params) {
|
||||||
@@ -42,6 +44,8 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
* Explicitly, since foreign keys are off outside the server process.
|
* Explicitly, since foreign keys are off outside the server process.
|
||||||
*/
|
*/
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
throw new Error(`Couldn't delete the bot: ${res.error}`);
|
throw new Error(`Couldn't delete the bot: ${res.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearEnvCache();
|
||||||
|
|
||||||
if (bot.image_media_id) {
|
if (bot.image_media_id) {
|
||||||
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import deleteEnv from "./delete-env";
|
||||||
|
import grabAllEnvs from "./grab-all-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
env_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one of the user's own personal secrets. Returns its key, for the
|
||||||
|
* log. Computers drop it on their next run.
|
||||||
|
*/
|
||||||
|
export default async function deleteUserSecret({ user_id, env_id }: Params) {
|
||||||
|
const env = (await grabAllEnvs()).find(
|
||||||
|
(e) => e.id == Number(env_id) && e.paradigm == "user" && e.user_id == Number(user_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!env) throw new AppError(`That secret no longer exists.`);
|
||||||
|
|
||||||
|
await deleteEnv({ env_id: env.id });
|
||||||
|
|
||||||
|
return env.key;
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ export type DecryptedEnv = {
|
|||||||
workspace_id: number | null;
|
workspace_id: number | null;
|
||||||
computer_id: number | null;
|
computer_id: number | null;
|
||||||
bot_id: number | null;
|
bot_id: number | null;
|
||||||
|
/**
|
||||||
|
* The user, for a personal env (paradigm user, with `bot_id`).
|
||||||
|
*/
|
||||||
|
user_id: number | null;
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
expose_to_computers: boolean;
|
expose_to_computers: boolean;
|
||||||
};
|
};
|
||||||
@@ -70,6 +74,7 @@ async function load() {
|
|||||||
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,
|
bot_id: typeof row.bot_id == "number" ? row.bot_id : null,
|
||||||
|
user_id: typeof row.user_id == "number" ? row.user_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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ type Params = {
|
|||||||
* The bot the run is for: its own envs beat everything else.
|
* The bot the run is for: its own envs beat everything else.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string | null;
|
bot_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for the bot apply.
|
||||||
|
*/
|
||||||
|
user_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
|
||||||
@@ -19,7 +23,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, bot_id, workspace_id }: Params) {
|
export default async function grabComputerEnvs({ computer_id, bot_id, user_id, workspace_id }: Params) {
|
||||||
const linked = workspace_id
|
const linked = workspace_id
|
||||||
? (
|
? (
|
||||||
await BunSQLite.sql<{ id: number }>({
|
await BunSQLite.sql<{ id: number }>({
|
||||||
@@ -34,6 +38,7 @@ export default async function grabComputerEnvs({ computer_id, bot_id, workspace_
|
|||||||
return resolveScopedEnvs({
|
return resolveScopedEnvs({
|
||||||
envs: await grabAllEnvs(),
|
envs: await grabAllEnvs(),
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_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)] : [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
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, { type DecryptedEnv } from "./grab-all-envs";
|
import grabAllEnvs, { type DecryptedEnv } from "./grab-all-envs";
|
||||||
|
import grabPersonalSecrets from "./grab-personal-secrets";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -19,10 +20,11 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* The Environment settings page: where envs can be set (general, every
|
* The Environment settings page: where envs can be set (general, every
|
||||||
* workspace, bot 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.
|
* the app settings with where each comes from, and every user's personal
|
||||||
|
* secrets (set from the chat, listed apart, values never shown).
|
||||||
*/
|
*/
|
||||||
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> {
|
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> {
|
||||||
const [workspaces, bots, computers, envs] = await Promise.all([
|
const [workspaces, bots, computers, envs, personal] = 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`,
|
||||||
}),
|
}),
|
||||||
@@ -33,6 +35,7 @@ export default async function grabEnvsOverview(_params: Params): Promise<EnvsOve
|
|||||||
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(),
|
||||||
|
grabPersonalSecrets(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const known_keys = new Set(EnvDict.map((env) => env.key));
|
const known_keys = new Set(EnvDict.map((env) => env.key));
|
||||||
@@ -58,7 +61,8 @@ export default async function grabEnvsOverview(_params: Params): Promise<EnvsOve
|
|||||||
})),
|
})),
|
||||||
].sort(compareScopes),
|
].sort(compareScopes),
|
||||||
],
|
],
|
||||||
envs: envs.map((env) => publicEnv({ env, known_keys })),
|
envs: envs.filter((env) => env.paradigm != "user").map((env) => publicEnv({ env, known_keys })),
|
||||||
|
personal,
|
||||||
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),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { PersonalSecret } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Only this user's. Left out lists everyone's, for the super admin's
|
||||||
|
* Environment page.
|
||||||
|
*/
|
||||||
|
user_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal secrets (envs with paradigm `user`) as settings pages list
|
||||||
|
* them: key, description, bot and user, never the value.
|
||||||
|
*/
|
||||||
|
export default async function grabPersonalSecrets({ user_id }: Params = {}): Promise<PersonalSecret[]> {
|
||||||
|
const res = await BunSQLite.sql<PersonalSecret>({
|
||||||
|
sql: `SELECT e.id, e.key, e.description, e.bot_id, e.user_id, e.updated_at,
|
||||||
|
COALESCE(b.name, 'Bot #' || e.bot_id) AS bot_name,
|
||||||
|
COALESCE(NULLIF(TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), ''), u.username, u.email, 'User #' || e.user_id) AS user_name
|
||||||
|
FROM envs e
|
||||||
|
LEFT JOIN bots b ON b.id = e.bot_id
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
WHERE e.paradigm = 'user' ${user_id ? "AND e.user_id = ?" : ""}
|
||||||
|
ORDER BY bot_name COLLATE NOCASE ASC, e.key ASC`,
|
||||||
|
values: user_id ? [Number(user_id)] : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const env = (fields: Partial<DecryptedEnv> & { key: string; value: string }): De
|
|||||||
workspace_id: null,
|
workspace_id: null,
|
||||||
computer_id: null,
|
computer_id: null,
|
||||||
bot_id: null,
|
bot_id: null,
|
||||||
|
user_id: null,
|
||||||
secret: true,
|
secret: true,
|
||||||
expose_to_computers: true,
|
expose_to_computers: true,
|
||||||
...fields,
|
...fields,
|
||||||
@@ -81,4 +82,20 @@ describe("resolveScopedEnvs", () => {
|
|||||||
|
|
||||||
expect(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [] })).toEqual([]);
|
expect(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [] })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a user's personal env beats the bot's own, for that user and bot only", () => {
|
||||||
|
const envs = [
|
||||||
|
env({ key: "TOKEN", value: "bot", paradigm: "bot", bot_id: 4 }),
|
||||||
|
env({ key: "TOKEN", value: "mine", paradigm: "user", bot_id: 4, user_id: 9 }),
|
||||||
|
env({ key: "OTHER_BOT", value: "no", paradigm: "user", bot_id: 5, user_id: 9 }),
|
||||||
|
env({ key: "OTHER_USER", value: "no", paradigm: "user", bot_id: 4, user_id: 10 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(values(resolveScopedEnvs({ envs, bot_id: 4, user_id: 9, computer_id: 7 }))).toEqual({
|
||||||
|
TOKEN: "mine",
|
||||||
|
});
|
||||||
|
expect(values(resolveScopedEnvs({ envs, bot_id: 4, computer_id: 7 }))).toEqual({
|
||||||
|
TOKEN: "bot",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ type Params = {
|
|||||||
* The bot the run is for: its own envs beat everything else.
|
* The bot the run is for: its own envs beat everything else.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string | null;
|
bot_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for this bot beat the
|
||||||
|
* bot's own.
|
||||||
|
*/
|
||||||
|
user_id?: number | string | null;
|
||||||
computer_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).
|
||||||
@@ -15,7 +20,8 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The envs that reach one run, one per key: the bot's own beat the
|
* The envs that reach one run, one per key: the user's personal ones for
|
||||||
|
* this bot beat the bot's own, which beat the
|
||||||
* computer's, which beat its workspaces' (the first listed workspace wins
|
* computer's, which beat its workspaces' (the first listed workspace wins
|
||||||
* a tie), which beat general ones. Only envs exposed to computers; app
|
* a tie), which beat general ones. Only envs exposed to computers; app
|
||||||
* settings never are.
|
* settings never are.
|
||||||
@@ -23,6 +29,7 @@ type Params = {
|
|||||||
export default function resolveScopedEnvs({
|
export default function resolveScopedEnvs({
|
||||||
envs,
|
envs,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
computer_id,
|
computer_id,
|
||||||
workspace_ids = [],
|
workspace_ids = [],
|
||||||
}: Params): DecryptedEnv[] {
|
}: Params): DecryptedEnv[] {
|
||||||
@@ -47,6 +54,14 @@ export default function resolveScopedEnvs({
|
|||||||
bot_id
|
bot_id
|
||||||
? exposed.filter((env) => env.paradigm == "bot" && env.bot_id == Number(bot_id))
|
? exposed.filter((env) => env.paradigm == "bot" && env.bot_id == Number(bot_id))
|
||||||
: [],
|
: [],
|
||||||
|
bot_id && user_id
|
||||||
|
? exposed.filter(
|
||||||
|
(env) =>
|
||||||
|
env.paradigm == "user" &&
|
||||||
|
env.bot_id == Number(bot_id) &&
|
||||||
|
env.user_id == Number(user_id),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ type Params = {
|
|||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
computer_id?: number | null;
|
computer_id?: number | null;
|
||||||
bot_id?: number | null;
|
bot_id?: number | null;
|
||||||
|
/**
|
||||||
|
* The user a personal env (paradigm user, with `bot_id`) is for.
|
||||||
|
*/
|
||||||
|
owner_user_id?: number | null;
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
expose_to_computers: boolean;
|
expose_to_computers: boolean;
|
||||||
};
|
};
|
||||||
@@ -37,7 +41,7 @@ type Params = {
|
|||||||
* Adds or edits an env. One per key and scope: SQLite unique constraints
|
* Adds or edits an env. One per key and scope: SQLite unique constraints
|
||||||
* treat NULL ids as distinct, so the check is here. App settings
|
* treat NULL ids as distinct, so the check is here. App settings
|
||||||
* (`EnvDict`) are general only, never go to computers, and keep their
|
* (`EnvDict`) are general only, never go to computers, and keep their
|
||||||
* dict's secret flag.
|
* dict's secret flag. Returns the env's id.
|
||||||
*/
|
*/
|
||||||
export default async function saveEnv({
|
export default async function saveEnv({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -49,9 +53,10 @@ export default async function saveEnv({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id,
|
computer_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
owner_user_id,
|
||||||
secret,
|
secret,
|
||||||
expose_to_computers,
|
expose_to_computers,
|
||||||
}: Params) {
|
}: Params): Promise<number> {
|
||||||
const final_key = key.trim();
|
const final_key = key.trim();
|
||||||
|
|
||||||
if (!EnvKeyRegex.test(final_key)) {
|
if (!EnvKeyRegex.test(final_key)) {
|
||||||
@@ -68,13 +73,13 @@ 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, bot_id });
|
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id, bot_id, owner_user_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 COALESCE(bot_id, 0) = ? AND COALESCE(user_id, 0) = ?
|
||||||
AND id != ? LIMIT 1`,
|
AND id != ? LIMIT 1`,
|
||||||
values: [
|
values: [
|
||||||
final_key,
|
final_key,
|
||||||
@@ -82,6 +87,7 @@ export default async function saveEnv({
|
|||||||
scope.workspace_id ?? 0,
|
scope.workspace_id ?? 0,
|
||||||
scope.computer_id ?? 0,
|
scope.computer_id ?? 0,
|
||||||
scope.bot_id ?? 0,
|
scope.bot_id ?? 0,
|
||||||
|
scope.user_id ?? 0,
|
||||||
env_id ?? 0,
|
env_id ?? 0,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -98,6 +104,7 @@ export default async function saveEnv({
|
|||||||
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,
|
bot_id: scope.bot_id,
|
||||||
|
user_id: scope.user_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,
|
||||||
};
|
};
|
||||||
@@ -126,6 +133,10 @@ export default async function saveEnv({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||||
|
|
||||||
|
clearEnvCache();
|
||||||
|
|
||||||
|
return env_id;
|
||||||
} else {
|
} else {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
throw new AppError(`Enter a value.`);
|
throw new AppError(`Enter a value.`);
|
||||||
@@ -134,16 +145,18 @@ export default async function saveEnv({
|
|||||||
const row = { ...fields, value: await encrypt(value) };
|
const row = { ...fields, value: await encrypt(value) };
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const res = await BunSQLite.sql({
|
const res = await BunSQLite.sql<{ id: number }>({
|
||||||
sql: `INSERT INTO envs (created_at, updated_at, ${Object.keys(row).join(", ")})
|
sql: `INSERT INTO envs (created_at, updated_at, ${Object.keys(row).join(", ")})
|
||||||
VALUES (?, ?, ${Object.keys(row).map(() => "?").join(", ")})`,
|
VALUES (?, ?, ${Object.keys(row).map(() => "?").join(", ")}) RETURNING id`,
|
||||||
values: [now, now, ...Object.values(row)] as any[],
|
values: [now, now, ...Object.values(row)] as any[],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||||
}
|
|
||||||
|
|
||||||
clearEnvCache();
|
clearEnvCache();
|
||||||
|
|
||||||
|
return Number(res.payload?.[0]?.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveScope({
|
async function resolveScope({
|
||||||
@@ -152,15 +165,30 @@ async function resolveScope({
|
|||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id,
|
computer_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
owner_user_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;
|
bot_id?: number | null;
|
||||||
}) {
|
owner_user_id?: number | null;
|
||||||
|
}): Promise<{
|
||||||
|
workspace_id: number | null;
|
||||||
|
computer_id: number | null;
|
||||||
|
bot_id: number | null;
|
||||||
|
user_id: number | null;
|
||||||
|
}> {
|
||||||
if (paradigm == "general") {
|
if (paradigm == "general") {
|
||||||
return { workspace_id: null, computer_id: null, bot_id: null };
|
return { workspace_id: null, computer_id: null, bot_id: null, user_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paradigm == "user") {
|
||||||
|
if (!owner_user_id) throw new AppError(`A personal env needs its user.`);
|
||||||
|
|
||||||
|
const scope = await resolveScope({ user_id, paradigm: "bot", bot_id });
|
||||||
|
|
||||||
|
return { ...scope, user_id: Number(owner_user_id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const table =
|
const table =
|
||||||
@@ -181,8 +209,8 @@ async function resolveScope({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return paradigm == "workspace"
|
return paradigm == "workspace"
|
||||||
? { workspace_id: Number(id), computer_id: null, bot_id: null }
|
? { workspace_id: Number(id), computer_id: null, bot_id: null, user_id: null }
|
||||||
: paradigm == "bot"
|
: paradigm == "bot"
|
||||||
? { workspace_id: null, computer_id: null, bot_id: Number(id) }
|
? { workspace_id: null, computer_id: null, bot_id: Number(id), user_id: null }
|
||||||
: { workspace_id: null, computer_id: Number(id), bot_id: null };
|
: { workspace_id: null, computer_id: Number(id), bot_id: null, user_id: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabAllEnvs from "./grab-all-envs";
|
||||||
|
import saveEnv from "./save-env";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Whose secret it is: only their runs of the bot get it, and only they
|
||||||
|
* may reveal it.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a user's personal secret for one bot (an env with paradigm
|
||||||
|
* `user`): what they typed into a secret request's card, or what a bot
|
||||||
|
* captured on a computer for them. Always secret and put on computers, so
|
||||||
|
* it reaches that user's runs by key and is redacted from everything the
|
||||||
|
* bot reads. Replaces the value when they already have that key for the
|
||||||
|
* bot. The user must still reach the bot. Returns the env's id.
|
||||||
|
*/
|
||||||
|
export default async function saveUserSecret({ user_id, bot_id, key, value, description }: Params) {
|
||||||
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
|
||||||
|
if (!bot?.id) throw new AppError(`You no longer have this bot.`);
|
||||||
|
|
||||||
|
if (!value) throw new AppError(`Enter a value.`);
|
||||||
|
|
||||||
|
const existing = (await grabAllEnvs()).find(
|
||||||
|
(env) =>
|
||||||
|
env.paradigm == "user" &&
|
||||||
|
env.bot_id == Number(bot_id) &&
|
||||||
|
env.user_id == Number(user_id) &&
|
||||||
|
env.key == key.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return await saveEnv({
|
||||||
|
user_id: Number(user_id),
|
||||||
|
env_id: existing?.id,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
description: description ?? existing?.description,
|
||||||
|
paradigm: "user",
|
||||||
|
bot_id: Number(bot_id),
|
||||||
|
owner_user_id: Number(user_id),
|
||||||
|
secret: true,
|
||||||
|
expose_to_computers: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ type Params = {
|
|||||||
* The bot the run is for: its own envs beat everything else.
|
* The bot the run is for: its own envs beat everything else.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string | null;
|
bot_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for this bot beat the
|
||||||
|
* bot's own.
|
||||||
|
*/
|
||||||
|
user_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.
|
||||||
*/
|
*/
|
||||||
@@ -14,11 +19,12 @@ 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 bot's own beat the workspace's, which beat
|
* on one run, one per key: the user's personal ones for this bot beat the
|
||||||
|
* bot's own, which beat the workspace's, which beat
|
||||||
* general ones. App settings (`EnvDict`) never are. Unlike computers' envs,
|
* general ones. App settings (`EnvDict`) never are. Unlike computers' envs,
|
||||||
* they don't have to be set on computers: the app fills them in itself.
|
* they don't have to be set on computers: the app fills them in itself.
|
||||||
*/
|
*/
|
||||||
export default async function grabRunEnvs({ bot_id, workspace_id }: Params = {}): Promise<DecryptedEnv[]> {
|
export default async function grabRunEnvs({ bot_id, user_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>();
|
||||||
@@ -39,5 +45,13 @@ export default async function grabRunEnvs({ bot_id, workspace_id }: Params = {})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bot_id && user_id) {
|
||||||
|
for (const env of envs.filter(
|
||||||
|
(e) => e.paradigm == "user" && e.bot_id == Number(bot_id) && e.user_id == Number(user_id),
|
||||||
|
)) {
|
||||||
|
resolved.set(env.key, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [...resolved.values()];
|
return [...resolved.values()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import saveUserSecret from "../envs/save-user-secret";
|
||||||
|
import grabSecretRequest from "./grab-secret-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
request_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user typed the secret a bot asked for into its card: claims the
|
||||||
|
* request once (only a pending one moves, so a double click saves once),
|
||||||
|
* then saves the value as their personal secret for that bot
|
||||||
|
* (`saveUserSecret`). A failed save puts the request back to pending.
|
||||||
|
* The value is never stored on the request.
|
||||||
|
*/
|
||||||
|
export default async function answerSecretRequest({ request_id, user_id, value }: Params) {
|
||||||
|
const request = await grabSecretRequest({ request_id, user_id });
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `UPDATE secret_requests SET status = 'saved', decided_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'pending' RETURNING id`,
|
||||||
|
values: [now, now, Number(request.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!claimed.payload?.[0]) throw new AppError(`This request was already ${request.status}.`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env_id = await saveUserSecret({
|
||||||
|
user_id,
|
||||||
|
bot_id: request.bot_id || 0,
|
||||||
|
key: request.key || "",
|
||||||
|
value,
|
||||||
|
description: request.description || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE secret_requests SET env_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [env_id, Date.now(), Number(request.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return request;
|
||||||
|
} catch (error) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE secret_requests SET status = 'pending', decided_at = NULL, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [Date.now(), Number(request.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_SECRET_REQUESTS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import checkSecretKey from "@/src/utils/check-secret-key";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
session_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a secret a bot asks the user for, pending until they type it
|
||||||
|
* into its card. A request still pending for the same key in the same
|
||||||
|
* conversation is reused (its description and reason updated), so asking
|
||||||
|
* twice shows one card. Returns its id.
|
||||||
|
*/
|
||||||
|
export default async function createSecretRequest({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
key,
|
||||||
|
description,
|
||||||
|
reason,
|
||||||
|
}: Params) {
|
||||||
|
const final_key = checkSecretKey({ key });
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const pending = (
|
||||||
|
await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `UPDATE secret_requests SET description = ?, reason = ?, updated_at = ?
|
||||||
|
WHERE session_id = ? AND user_id = ? AND bot_id = ? AND key = ? AND status = 'pending'
|
||||||
|
RETURNING id`,
|
||||||
|
values: [description, reason, now, Number(session_id), Number(user_id), Number(bot_id), final_key],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (pending?.id) return Number(pending.id);
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SECRET_REQUESTS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "secret_requests",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
key: final_key,
|
||||||
|
description,
|
||||||
|
reason,
|
||||||
|
status: "pending",
|
||||||
|
user_id: Number(user_id),
|
||||||
|
bot_id: Number(bot_id),
|
||||||
|
session_id: Number(session_id),
|
||||||
|
workspace_id: workspace_id ? Number(workspace_id) : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
|
||||||
|
|
||||||
|
if (!res.success || !id) throw new Error(`Couldn't store the request: ${res.msg}`);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabSecretRequest from "./grab-secret-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
request_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user declined a secret a bot asked for: nothing is saved.
|
||||||
|
*/
|
||||||
|
export default async function declineSecretRequest({ request_id, user_id }: Params) {
|
||||||
|
const request = await grabSecretRequest({ request_id, user_id });
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `UPDATE secret_requests SET status = 'declined', decided_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'pending' RETURNING id`,
|
||||||
|
values: [now, now, Number(request.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!claimed.payload?.[0]) throw new AppError(`This request was already ${request.status}.`);
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { SecretRequestStatus } from "@/src/dict/env-dict";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
import grabSecretRequest from "./grab-secret-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
request_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret request as its chip and card show it: the key, what it's for
|
||||||
|
* and why the bot asks. Never a value: the request never holds one.
|
||||||
|
*/
|
||||||
|
export default async function grabSecretRequestView({ request_id, user_id }: Params): Promise<SecretRequestView> {
|
||||||
|
const row = await grabSecretRequest({ request_id, user_id });
|
||||||
|
|
||||||
|
const bot = (
|
||||||
|
await BunSQLite.sql<{ name: string }>({
|
||||||
|
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
|
||||||
|
values: [row.bot_id || 0],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
key: row.key || "",
|
||||||
|
description: row.description || "",
|
||||||
|
reason: row.reason || "",
|
||||||
|
status: (row.status || "pending") as SecretRequestStatus,
|
||||||
|
bot_id: Number(row.bot_id),
|
||||||
|
bot_name: bot?.name || "The bot",
|
||||||
|
env_id: row.env_id ? Number(row.env_id) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_SECRET_REQUESTS } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
request_id: number | string;
|
||||||
|
/**
|
||||||
|
* Only the user it was asked of reads it.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret request, only for the user it was asked of. Throws otherwise.
|
||||||
|
*/
|
||||||
|
export default async function grabSecretRequest({ request_id, user_id }: Params) {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_SECRET_REQUESTS>({
|
||||||
|
sql: `SELECT * FROM secret_requests WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||||
|
values: [Number(request_id), Number(user_id)],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row?.id) throw new AppError(`That request isn't yours or no longer exists.`);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import canRevealEnv from "@/src/utils/rbac/can-reveal-env";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabAllEnvs from "../envs/grab-all-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
env_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An env a secret ref (`[secret:ID]`) names, with whether this viewer may
|
||||||
|
* reveal it (`canRevealEnv`). Someone else's personal secret reads as
|
||||||
|
* missing: not even its key is shown to anyone else.
|
||||||
|
*/
|
||||||
|
export default async function grabRevealableEnv({ env_id, user_id }: Params) {
|
||||||
|
const env = (await grabAllEnvs()).find((e) => e.id == Number(env_id));
|
||||||
|
|
||||||
|
if (!env || (env.paradigm == "user" && env.user_id != Number(user_id))) {
|
||||||
|
throw new AppError(`That secret isn't yours or no longer exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await grabCrewRole({ user_id });
|
||||||
|
const reaches_bot =
|
||||||
|
env.paradigm == "user" && env.bot_id ? Boolean((await grabUserBot({ bot_id: env.bot_id, user_id }))?.id) : false;
|
||||||
|
|
||||||
|
return { env, can_reveal: canRevealEnv({ env, viewer_id: user_id, role, reaches_bot }) };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import AppError from "@/src/utils/app-error";
|
|||||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||||
|
import { clearEnvCache } from "../envs/grab-all-envs";
|
||||||
import grabUsers from "./grab-users";
|
import grabUsers from "./grab-users";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -20,8 +21,8 @@ type Params = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a user with everything that's theirs (their conversations,
|
* Deletes a user with everything that's theirs (their conversations,
|
||||||
* routines, access, what bots remember about them, user types, password,
|
* routines, access, what bots remember about them, personal secrets, user
|
||||||
* media and its files). Bots, workspaces and computers belong to the
|
* types, password, media and its files). Bots, workspaces and computers belong to the
|
||||||
* install and stay, pictures they uploaded for them too. Their audit log
|
* install and stay, pictures they uploaded for them too. Their audit log
|
||||||
* entries stay as well. The super admin can't be deleted, and nobody
|
* entries stay as well. The super admin can't be deleted, and nobody
|
||||||
* deletes themselves.
|
* deletes themselves.
|
||||||
@@ -70,9 +71,16 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
|
|||||||
/**
|
/**
|
||||||
* These point at the user without a foreign key, so nothing cascades.
|
* These point at the user without a foreign key, so nothing cascades.
|
||||||
*/
|
*/
|
||||||
for (const table of ["user_types", "user_passwords", "sso_login_codes"]) {
|
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests"]) {
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
|
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Their personal secrets, explicitly: foreign keys are off outside the
|
||||||
|
* server process.
|
||||||
|
*/
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE paradigm = 'user' AND user_id = ?`, values: [user.id] });
|
||||||
|
clearEnvCache();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
env_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret's value, for a Reveal or Copy click. Throws why when the
|
||||||
|
* viewer may not see it.
|
||||||
|
*/
|
||||||
|
export default async function revealSecret({ env_id }: Params): Promise<string> {
|
||||||
|
const res = await fetchApi<{ env_id: number }, APIResponseObject<{ value: string }>>(
|
||||||
|
`/api/admin/secrets/reveal-secret`,
|
||||||
|
{ method: "POST", body: { env_id } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res?.success || typeof res.singleRes?.value != "string") {
|
||||||
|
throw new Error(res?.msg || `Couldn't read the secret.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.singleRes.value;
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ export default async function grabBotChatTools({
|
|||||||
? [
|
? [
|
||||||
searchComputersTool({
|
searchComputersTool({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
computers,
|
computers,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
dirs: computerRunDirs({ bot_id, bot_kind: bot.kind, user_id, workspace_id }),
|
dirs: computerRunDirs({ bot_id, bot_kind: bot.kind, user_id, workspace_id }),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { HarnessTool } from "../../../types";
|
||||||
|
import requestSecretTool from "./request-secret-tool";
|
||||||
|
import showSecretTool from "./show-secret-tool";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
session_id?: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
computer_id?: number | string | null;
|
||||||
|
in_task?: boolean;
|
||||||
|
/**
|
||||||
|
* Called with a secret request's ref: the message needs the user.
|
||||||
|
*/
|
||||||
|
onRequest?: (params: { ref: string }) => void;
|
||||||
|
/**
|
||||||
|
* Called with a shown secret's ref.
|
||||||
|
*/
|
||||||
|
onShow?: (params: { ref: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tools that move secrets without the model seeing them: asking the
|
||||||
|
* user for one (`request_secret`, needs a conversation to show its card
|
||||||
|
* in) and showing one (`show_secret`). Capturing one on a computer is a
|
||||||
|
* computer tool (`capture_secret`).
|
||||||
|
*/
|
||||||
|
export default function grabBotSecretTools({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id,
|
||||||
|
in_task,
|
||||||
|
onRequest,
|
||||||
|
onShow,
|
||||||
|
}: Params): HarnessTool[] {
|
||||||
|
return [
|
||||||
|
...(session_id && onRequest
|
||||||
|
? [requestSecretTool({ user_id, bot_id, session_id, workspace_id, in_task, onRequest })]
|
||||||
|
: []),
|
||||||
|
...(onShow ? [showSecretTool({ user_id, bot_id, workspace_id, computer_id, onShow })] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import createSecretRequest from "@/src/functions/backend/db/secret-requests/create-secret-request";
|
||||||
|
import secretRequestRef from "@/src/utils/secret-request-ref";
|
||||||
|
import type { HarnessTool } from "../../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The conversation it's asked in, where its card shows.
|
||||||
|
*/
|
||||||
|
session_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Whether the run is a task, which stops and reports rather than waits.
|
||||||
|
*/
|
||||||
|
in_task?: boolean;
|
||||||
|
/**
|
||||||
|
* Called with the request's ref, so the message always carries its card.
|
||||||
|
*/
|
||||||
|
onRequest: (params: { ref: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a bot ask the user for a secret (a password, token or API key)
|
||||||
|
* without it going through the chat: the user types it into a card, it's
|
||||||
|
* saved as their personal secret for this bot, and the bot only ever
|
||||||
|
* learns that it's saved and its key.
|
||||||
|
*/
|
||||||
|
export default function requestSecretTool({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
in_task,
|
||||||
|
onRequest,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "request_secret",
|
||||||
|
description: `Ask the user for a secret you need (a password, token or API key) through a masked card in the chat, never by asking them to type it into a message: what they type there goes to you and the model provider, the card's value never does. It's saved as their own secret for you, under key, and set as $KEY in your shell (and {{env.KEY}} for tools) on this user's later runs; you never see the value. Check your envs first: if the key is already set, don't ask. Their answer comes as a message saying it's saved${
|
||||||
|
in_task ? `. In a task, ask, then stop and report that you're waiting for it.` : `.`
|
||||||
|
}`,
|
||||||
|
schema: z.object({
|
||||||
|
key: z.string().trim().min(1).max(128).describe("The env key to save it under, e.g. GITHUB_TOKEN"),
|
||||||
|
description: z.string().trim().min(1).max(500).describe("What it is, shown on the card, e.g. \"GitHub personal access token with repo scope\""),
|
||||||
|
reason: z.string().trim().min(10, "Say why you need it").max(2000).describe("Why you need it and what you'll do with it, shown on the card as your words"),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const id = await createSecretRequest({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
|
key: args.key,
|
||||||
|
description: args.description,
|
||||||
|
reason: args.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
onRequest({ ref: secretRequestRef({ id }) });
|
||||||
|
|
||||||
|
return `Asked the user for ${args.key.trim()}: the card is on your message. Their answer arrives as a message once it's saved; then it's $${args.key.trim()} on your computers. Never ask them to paste it in the chat.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
|
||||||
|
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
|
||||||
|
import secretRef from "@/src/utils/secret-ref";
|
||||||
|
import type { HarnessTool } from "../../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The computer the run is on, whose envs count too.
|
||||||
|
*/
|
||||||
|
computer_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Called with the secret's ref, so the message always carries its chip.
|
||||||
|
*/
|
||||||
|
onShow: (params: { ref: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a bot show the user an env it has (by key) as a masked chip, without
|
||||||
|
* ever holding the value: it gets a `[secret:ID]` ref, and the chat
|
||||||
|
* fetches the value only for whoever may see it (`canRevealEnv`).
|
||||||
|
*/
|
||||||
|
export default function showSecretTool({ user_id, bot_id, workspace_id, computer_id, onShow }: Params): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "show_secret",
|
||||||
|
description: `Show the user one of your envs (by key, e.g. a password or token they saved or you captured) without reading it: you get a [secret:ID] ref to put in your message, which the user sees masked with Reveal and Copy. Never print a secret with a command to show it. Only its owner (for a personal secret) or a super admin can reveal it.`,
|
||||||
|
schema: z.object({
|
||||||
|
key: z.string().trim().min(1).max(128),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const key = args.key.trim();
|
||||||
|
const on_computer = computer_id
|
||||||
|
? await grabComputerEnvs({ computer_id, bot_id, user_id, workspace_id })
|
||||||
|
: [];
|
||||||
|
const env =
|
||||||
|
on_computer.find((e) => e.key == key) ||
|
||||||
|
(await grabRunEnvs({ bot_id, user_id, workspace_id })).find((e) => e.key == key);
|
||||||
|
|
||||||
|
if (!env) return `No env called ${key} reaches you here. Ask the user with request_secret if you need it.`;
|
||||||
|
|
||||||
|
const ref = secretRef({ id: env.id });
|
||||||
|
|
||||||
|
onShow({ ref });
|
||||||
|
|
||||||
|
return `Put ${ref} in your message: the user sees ${key} masked, with Reveal and Copy. Never repeat or guess the value.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import saveUserSecret from "@/src/functions/backend/db/envs/save-user-secret";
|
||||||
|
import { resolveComputerPath, type ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||||
|
import secretRef from "@/src/utils/secret-ref";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import computerDirsShell from "./computer-dirs-shell";
|
||||||
|
import downloadComputerFile from "./download-computer-file";
|
||||||
|
import runComputerCommand from "./run-computer-command";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The sandbox's REST address (no `/mcp`).
|
||||||
|
*/
|
||||||
|
base_url: string;
|
||||||
|
/**
|
||||||
|
* The computer's env file, sourced before a command.
|
||||||
|
*/
|
||||||
|
shell_path: string;
|
||||||
|
dirs: ComputerRunDirs;
|
||||||
|
/**
|
||||||
|
* Whose secret it becomes: the run's user, for this bot.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Called with each captured value and its key, so the run blanks it
|
||||||
|
* out of everything the bot reads from then on.
|
||||||
|
*/
|
||||||
|
onCapture: (params: { key: string; value: string; ref: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
from: "file" | "command";
|
||||||
|
path?: string;
|
||||||
|
command?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a secret that's on the computer (a generated password, a token a
|
||||||
|
* CLI printed, a key file) as the user's personal secret for this bot,
|
||||||
|
* without the bot ever reading it: the file comes over the sandbox's
|
||||||
|
* download endpoint, a command's output over its REST shell, and neither
|
||||||
|
* is returned. The bot gets a `[secret:ID]` ref to show the user, and the
|
||||||
|
* value is redacted from everything it reads afterwards.
|
||||||
|
*/
|
||||||
|
export default function captureSecretTool({
|
||||||
|
base_url,
|
||||||
|
shell_path,
|
||||||
|
dirs,
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
onCapture,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
return {
|
||||||
|
name: "capture_secret",
|
||||||
|
description: `Save a secret that's on this computer (a password you generated, a token or API key a command printed, a key file) as the user's own secret for you, without reading it yourself: never cat, echo or print a secret, capture it instead. from "file" saves a file's contents (path in your folders); from "command" saves what a bash command prints (stdout, with your envs set), e.g. "printenv NAME" or "openssl rand -base64 24 | tee pw.txt". One trailing newline is dropped. You get back a [secret:ID] ref: put it in your message and the user sees the secret masked, with Reveal and Copy. It's also set as $KEY on your computers for this user's later runs. Values over ${AppData.CapturedSecretMaxChars} characters are refused.`,
|
||||||
|
schema: z.object({
|
||||||
|
key: z.string().trim().min(1).max(128).describe("The env key it's saved under, e.g. ADMIN_PASSWORD"),
|
||||||
|
description: z.string().trim().min(1).max(500).describe("What it is, for the user"),
|
||||||
|
from: z.enum(["file", "command"]),
|
||||||
|
path: z.string().trim().min(1).max(1000).optional().describe("For from file"),
|
||||||
|
command: z.string().trim().min(1).max(4000).optional().describe("For from command"),
|
||||||
|
}),
|
||||||
|
run: async ({ args, signal }) => {
|
||||||
|
const value = await readValue({ args, base_url, shell_path, dirs, signal });
|
||||||
|
|
||||||
|
if (value.text === undefined) return value.error;
|
||||||
|
|
||||||
|
const env_id = await saveUserSecret({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
key: args.key,
|
||||||
|
value: value.text,
|
||||||
|
description: args.description,
|
||||||
|
});
|
||||||
|
const ref = secretRef({ id: env_id });
|
||||||
|
|
||||||
|
onCapture({ key: args.key.trim(), value: value.text, ref });
|
||||||
|
|
||||||
|
return `Saved as ${args.key.trim()} (${value.text.length} characters). Put ${ref} in your message to show it to the user; never repeat or guess the value.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readValue({
|
||||||
|
args,
|
||||||
|
base_url,
|
||||||
|
shell_path,
|
||||||
|
dirs,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
args: Args;
|
||||||
|
base_url: string;
|
||||||
|
shell_path: string;
|
||||||
|
dirs: ComputerRunDirs;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<{ text: string; error?: undefined } | { text?: undefined; error: string }> {
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
if (args.from == "file") {
|
||||||
|
if (!args.path) return { error: `Give the file's path.` };
|
||||||
|
|
||||||
|
const download = await downloadComputerFile({
|
||||||
|
base_url,
|
||||||
|
path: resolveComputerPath({ path: args.path, dirs }),
|
||||||
|
max_bytes: AppData.CapturedSecretMaxChars * 4,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!download.ok) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
download.reason == "missing"
|
||||||
|
? `There's no file at ${args.path}.`
|
||||||
|
: download.reason == "too_big"
|
||||||
|
? `That file is too big to be a secret.`
|
||||||
|
: `The file changed while it was read. Try again.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
text = download.bytes.toString("utf8");
|
||||||
|
} else {
|
||||||
|
if (!args.command) return { error: `Give the command.` };
|
||||||
|
|
||||||
|
const result = await runComputerCommand({
|
||||||
|
base_url,
|
||||||
|
command: `(${computerDirsShell({ dirs })}[ -f ${shell_path} ] && . ${shell_path}; ${args.command}) 2>/dev/null`,
|
||||||
|
timeout_ms: AppData.CaptureSecretTimeoutMs,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exit_code != 0) {
|
||||||
|
return { error: `The command failed (exit code ${result.exit_code ?? "unknown"}). Nothing was saved.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
text = result.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text.replace(/\r?\n$/, "");
|
||||||
|
|
||||||
|
if (!text) return { error: `It was empty. Nothing was saved.` };
|
||||||
|
|
||||||
|
if (text.length > AppData.CapturedSecretMaxChars) {
|
||||||
|
return { error: `That's over ${AppData.CapturedSecretMaxChars} characters, too long to be a secret. Nothing was saved.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import grabMcpTools from "../../mcp/grab-mcp-tools";
|
|||||||
import readImage from "../../media/read-image";
|
import readImage from "../../media/read-image";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
import computerDirsShell from "./computer-dirs-shell";
|
import computerDirsShell from "./computer-dirs-shell";
|
||||||
|
import captureSecretTool from "./capture-secret-tool";
|
||||||
import runComputerCommand from "./run-computer-command";
|
import runComputerCommand from "./run-computer-command";
|
||||||
import withComputerDirs from "./with-computer-dirs";
|
import withComputerDirs from "./with-computer-dirs";
|
||||||
import withComputerEnv from "./with-computer-env";
|
import withComputerEnv from "./with-computer-env";
|
||||||
@@ -21,6 +22,10 @@ type Params = {
|
|||||||
* Screenshots are read with this bot's media reading model.
|
* Screenshots are read with this bot's media reading model.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for the bot apply.
|
||||||
|
*/
|
||||||
|
user_id?: number | string | null;
|
||||||
/**
|
/**
|
||||||
* The workspace the run is in: its envs apply, and its models read
|
* The workspace the run is in: its envs apply, and its models read
|
||||||
* screenshots.
|
* screenshots.
|
||||||
@@ -31,6 +36,12 @@ type Params = {
|
|||||||
* kept inside them.
|
* kept inside them.
|
||||||
*/
|
*/
|
||||||
dirs: ComputerRunDirs;
|
dirs: ComputerRunDirs;
|
||||||
|
/**
|
||||||
|
* Called with the ref of each secret the bot captures on the computer
|
||||||
|
* (`capture_secret`), so the run's message always carries its chip.
|
||||||
|
* Without it, or without a user, the bot can't capture secrets.
|
||||||
|
*/
|
||||||
|
onSecretRef?: (params: { ref: string }) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,8 +72,10 @@ export type OpenComputer = {
|
|||||||
export default async function openComputerTools({
|
export default async function openComputerTools({
|
||||||
computer_id,
|
computer_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
dirs,
|
dirs,
|
||||||
|
onSecretRef,
|
||||||
signal,
|
signal,
|
||||||
}: Params): Promise<OpenComputer> {
|
}: Params): Promise<OpenComputer> {
|
||||||
const target = await grabComputerMcpUrl({ computer_id });
|
const target = await grabComputerMcpUrl({ computer_id });
|
||||||
@@ -75,7 +88,7 @@ export default async function openComputerTools({
|
|||||||
throw new Error(computerNotReadyMessage(target));
|
throw new Error(computerNotReadyMessage(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
const envs = await grabComputerEnvs({ computer_id, bot_id, workspace_id });
|
const envs = await grabComputerEnvs({ computer_id, bot_id, user_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 });
|
||||||
|
|
||||||
await runComputerCommand({
|
await runComputerCommand({
|
||||||
@@ -85,7 +98,11 @@ export default async function openComputerTools({
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const secrets = envs.filter((env) => env.secret);
|
/**
|
||||||
|
* Grows as the bot captures secrets, so a value it saved is blanked
|
||||||
|
* from everything it reads afterwards too.
|
||||||
|
*/
|
||||||
|
const secrets: { key: string; value: string }[] = envs.filter((env) => env.secret);
|
||||||
const redact = (text: string) => redactSecrets({ text, secrets });
|
const redact = (text: string) => redactSecrets({ text, secrets });
|
||||||
|
|
||||||
const client = await connectMcpServer({ url: target.url, signal });
|
const client = await connectMcpServer({ url: target.url, signal });
|
||||||
@@ -107,7 +124,24 @@ export default async function openComputerTools({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: target.name,
|
name: target.name,
|
||||||
tools: tools.map((tool) => withComputerDirs({ tool: withComputerEnv({ tool, ...paths }), dirs })),
|
tools: [
|
||||||
|
...tools.map((tool) => withComputerDirs({ tool: withComputerEnv({ tool, ...paths }), dirs })),
|
||||||
|
...(user_id && bot_id && onSecretRef
|
||||||
|
? [
|
||||||
|
captureSecretTool({
|
||||||
|
base_url: target.base_url,
|
||||||
|
shell_path: paths.shell_path,
|
||||||
|
dirs,
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
onCapture: ({ key, value, ref }) => {
|
||||||
|
secrets.push({ key, value });
|
||||||
|
onSecretRef({ ref });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
envs: envs.map((env) => ({ key: env.key, description: env.description })),
|
envs: envs.map((env) => ({ key: env.key, description: env.description })),
|
||||||
redact,
|
redact,
|
||||||
close: () => client.close().catch(() => {}),
|
close: () => client.close().catch(() => {}),
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type Params = {
|
|||||||
* The bot the run is for: its own envs beat everything.
|
* The bot the run is for: its own envs beat everything.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string | null;
|
bot_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for the bot apply.
|
||||||
|
*/
|
||||||
|
user_id?: number | string | null;
|
||||||
/**
|
/**
|
||||||
* The workspace the run is in: its envs apply.
|
* The workspace the run is in: its envs apply.
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +48,7 @@ type Args = {
|
|||||||
*/
|
*/
|
||||||
export default function searchComputersTool({
|
export default function searchComputersTool({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computers,
|
computers,
|
||||||
dirs,
|
dirs,
|
||||||
@@ -76,6 +81,7 @@ export default function searchComputersTool({
|
|||||||
grabComputerEnvs({
|
grabComputerEnvs({
|
||||||
computer_id: computer.id || 0,
|
computer_id: computer.id || 0,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
computer_id?: number | string | null;
|
computer_id?: number | string | null;
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for the bot apply.
|
||||||
|
*/
|
||||||
|
user_id?: number | string | null;
|
||||||
workspace_id?: number | string | null;
|
workspace_id?: number | string | null;
|
||||||
/**
|
/**
|
||||||
* The run's folders on the computer (`computerRunDirs`), where a
|
* The run's folders on the computer (`computerRunDirs`), where a
|
||||||
@@ -45,6 +49,7 @@ export default function buildCustomTool({
|
|||||||
envs,
|
envs,
|
||||||
computer_id,
|
computer_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
dirs,
|
dirs,
|
||||||
record = true,
|
record = true,
|
||||||
@@ -64,6 +69,7 @@ export default function buildCustomTool({
|
|||||||
args,
|
args,
|
||||||
computer_id: computer_id!,
|
computer_id: computer_id!,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
dirs,
|
dirs,
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type Params = {
|
|||||||
* The bot the run is for: its own envs beat everything.
|
* The bot the run is for: its own envs beat everything.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string | null;
|
bot_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for the bot apply.
|
||||||
|
*/
|
||||||
|
user_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.
|
||||||
*/
|
*/
|
||||||
@@ -44,6 +48,7 @@ export default async function runScriptTool({
|
|||||||
args,
|
args,
|
||||||
computer_id,
|
computer_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
dirs,
|
dirs,
|
||||||
signal,
|
signal,
|
||||||
@@ -53,7 +58,7 @@ export default async function runScriptTool({
|
|||||||
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, bot_id, workspace_id });
|
const envs = await grabComputerEnvs({ computer_id, bot_id, user_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];
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
|||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The user the run is for: their personal envs for the bot fill
|
||||||
|
* templates and reach scripts.
|
||||||
|
*/
|
||||||
|
user_id?: number | string | null;
|
||||||
workspace_id?: number | string | null;
|
workspace_id?: number | string | null;
|
||||||
/**
|
/**
|
||||||
* Where script tools run: the task's computer, or in chat the bot's
|
* Where script tools run: the task's computer, or in chat the bot's
|
||||||
@@ -54,6 +59,7 @@ export type OpenBotExtensions = {
|
|||||||
*/
|
*/
|
||||||
export default async function openBotExtensions({
|
export default async function openBotExtensions({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
user_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
computer_id,
|
computer_id,
|
||||||
dirs,
|
dirs,
|
||||||
@@ -66,7 +72,7 @@ export default async function openBotExtensions({
|
|||||||
return { tools: [], note: "", skills, close: async () => {} };
|
return { tools: [], note: "", skills, close: async () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const envs = await grabRunEnvs({ bot_id, workspace_id });
|
const envs = await grabRunEnvs({ bot_id, user_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);
|
||||||
@@ -81,7 +87,7 @@ export default async function openBotExtensions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
add(buildCustomTool({ tool, envs, computer_id, bot_id, workspace_id, dirs }));
|
add(buildCustomTool({ tool, envs, computer_id, bot_id, user_id, workspace_id, dirs }));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
request_id: number;
|
||||||
|
/**
|
||||||
|
* The secret the user typed; none declines the request.
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the secret a bot asked for from its card, or declines it,
|
||||||
|
* returning the request as it now stands (never the value).
|
||||||
|
*/
|
||||||
|
export default async function decideSecretRequest({ request_id, value }: Params) {
|
||||||
|
return await fetchApi<{ request_id: number; value?: string }, APIResponseObject<SecretRequestView>>(
|
||||||
|
`/api/admin/secret-requests/${value === undefined ? "decline" : "answer"}-request`,
|
||||||
|
{ method: "POST", body: value === undefined ? { request_id } : { request_id, value } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { SecretRefView } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
env_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret refs already read, by id. Only the key and whether the viewer may
|
||||||
|
* reveal it: the value is fetched on Reveal or Copy and never cached here.
|
||||||
|
*/
|
||||||
|
const loaded = new Map<number, Promise<SecretRefView | null>>();
|
||||||
|
|
||||||
|
function loadSecret({ env_id }: Params) {
|
||||||
|
const cached = loaded.get(env_id);
|
||||||
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const pending = fetchApi<{}, APIResponseObject<SecretRefView>>(
|
||||||
|
`/api/admin/secrets/get-secret?env_id=${env_id}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
)
|
||||||
|
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
loaded.set(env_id, pending);
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret a bot showed (`[secret:ID]`), for its masked chip.
|
||||||
|
*/
|
||||||
|
export default function useSecretRef({ env_id }: Params) {
|
||||||
|
const [view, setView] = useState<SecretRefView | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
|
||||||
|
loadSecret({ env_id }).then((result) => {
|
||||||
|
if (!live) return;
|
||||||
|
setView(result);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [env_id]);
|
||||||
|
|
||||||
|
return { view, loading };
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
request_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret requests already read, by id, so a thread naming one twice asks
|
||||||
|
* once. An answer replaces its entry.
|
||||||
|
*/
|
||||||
|
const loaded = new Map<number, Promise<SecretRequestView | null>>();
|
||||||
|
|
||||||
|
function loadRequest({ request_id }: Params) {
|
||||||
|
const cached = loaded.get(request_id);
|
||||||
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const pending = fetchApi<{}, APIResponseObject<SecretRequestView>>(
|
||||||
|
`/api/admin/secret-requests/get-request?request_id=${request_id}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
)
|
||||||
|
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
loaded.set(request_id, pending);
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret a bot asked for (`[secret-request:ID]`), for its chip and card:
|
||||||
|
* read when the chip renders, so it shows whether it's still waiting.
|
||||||
|
* Only the user it was asked of can read it; anyone else gets null.
|
||||||
|
*/
|
||||||
|
export default function useSecretRequest({ request_id }: Params) {
|
||||||
|
const [view, setViewState] = useState<SecretRequestView | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
|
||||||
|
loadRequest({ request_id }).then((result) => {
|
||||||
|
if (!live) return;
|
||||||
|
setViewState(result);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [request_id]);
|
||||||
|
|
||||||
|
const setView = useCallback(
|
||||||
|
(next: SecretRequestView) => {
|
||||||
|
loaded.set(request_id, Promise.resolve(next));
|
||||||
|
setViewState(next);
|
||||||
|
},
|
||||||
|
[request_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { view, loading, setView };
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
FileRefHrefPrefix,
|
FileRefHrefPrefix,
|
||||||
HookProposalRefHrefPrefix,
|
HookProposalRefHrefPrefix,
|
||||||
ProposalRefHrefPrefix,
|
ProposalRefHrefPrefix,
|
||||||
|
SecretRefHrefPrefix,
|
||||||
|
SecretRequestRefHrefPrefix,
|
||||||
TakeOverRefHrefPrefix,
|
TakeOverRefHrefPrefix,
|
||||||
WorkspaceRefHrefPrefix,
|
WorkspaceRefHrefPrefix,
|
||||||
} from "@/src/utils/link-computer-refs";
|
} from "@/src/utils/link-computer-refs";
|
||||||
@@ -17,6 +19,8 @@ import FileRefChip from "./file-ref-chip";
|
|||||||
import FolderRefChip from "./folder-ref-chip";
|
import FolderRefChip from "./folder-ref-chip";
|
||||||
import HookProposalRefChip from "./hook-proposal-ref-chip";
|
import HookProposalRefChip from "./hook-proposal-ref-chip";
|
||||||
import ProposalRefChip from "./proposal-ref-chip";
|
import ProposalRefChip from "./proposal-ref-chip";
|
||||||
|
import SecretRefChip from "./secret-ref-chip";
|
||||||
|
import SecretRequestRefChip from "./secret-request-ref-chip";
|
||||||
import TakeOverChip from "./take-over-chip";
|
import TakeOverChip from "./take-over-chip";
|
||||||
import WorkspaceRefChip from "./workspace-ref-chip";
|
import WorkspaceRefChip from "./workspace-ref-chip";
|
||||||
|
|
||||||
@@ -26,7 +30,9 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
|||||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||||
* a link to that workspace, a file it saved a chip that downloads it, a
|
* a link to that workspace, a file it saved a chip that downloads it, a
|
||||||
* folder it named a chip that opens it in the files browser, an MCP server,
|
* folder it named a chip that opens it in the files browser, an MCP server,
|
||||||
* tool, skill or hook it proposed a chip that opens the confirm card;
|
* tool, skill or hook it proposed a chip that opens the confirm card, a
|
||||||
|
* secret it showed a masked chip, a secret it asked for a chip that opens
|
||||||
|
* the card it's typed into;
|
||||||
* anything else opens in a new tab.
|
* anything else opens in a new tab.
|
||||||
*/
|
*/
|
||||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||||
@@ -81,6 +87,18 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
|||||||
if (proposal_id) return <HookProposalRefChip proposal_id={proposal_id} />;
|
if (proposal_id) return <HookProposalRefChip proposal_id={proposal_id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (href?.startsWith(SecretRefHrefPrefix)) {
|
||||||
|
const env_id = Number(href.slice(SecretRefHrefPrefix.length));
|
||||||
|
|
||||||
|
if (env_id) return <SecretRefChip env_id={env_id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href?.startsWith(SecretRequestRefHrefPrefix)) {
|
||||||
|
const request_id = Number(href.slice(SecretRequestRefHrefPrefix.length));
|
||||||
|
|
||||||
|
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
|
||||||
|
}
|
||||||
|
|
||||||
const folder_ref = parseFolderRefHref({ href });
|
const folder_ref = parseFolderRefHref({ href });
|
||||||
|
|
||||||
if (folder_ref) return <FolderRefChip spec={folder_ref.spec} path={folder_ref.path} />;
|
if (folder_ref) return <FolderRefChip spec={folder_ref.spec} path={folder_ref.path} />;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of a secret chip's icon buttons (Reveal, Copy).
|
||||||
|
*/
|
||||||
|
export default function SecretChipButton({ title, icon: Icon, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="smaller"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="h-5 w-5 shrink-0 p-0!"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon size={13} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { Check, Copy, Eye, EyeOff, KeyRound } from "lucide-react";
|
||||||
|
import revealSecret from "@/src/functions/frontend/reveal-secret";
|
||||||
|
import copyPendingText from "@/src/utils/copy-pending-text";
|
||||||
|
import useSecretRef from "../(hooks)/use-secret-ref";
|
||||||
|
import SecretChipButton from "./secret-chip-button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
env_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret a bot showed (`secretRef`): its key, masked, with Reveal and
|
||||||
|
* Copy for whoever may see it (`canRevealEnv`). The value is fetched only
|
||||||
|
* on a click, held only while shown, and never in the page or the thread.
|
||||||
|
*/
|
||||||
|
export default function SecretRefChip({ env_id }: Props) {
|
||||||
|
const { view, loading } = useSecretRef({ env_id });
|
||||||
|
const [value, setValue] = useState<string>();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
return (
|
||||||
|
<Span className="font-bold">{loading ? `secret ${env_id}` : `secret ${env_id} (not available)`}</Span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reveal = async () => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
setValue(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setValue(await revealSecret({ env_id }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const ok = await copyPendingText({ text: revealSecret({ env_id }) }).catch((e: Error) => {
|
||||||
|
setError(e.message);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
title={view.description || view.key}
|
||||||
|
className="inline-flex h-6 max-w-full gap-1! flex-nowrap items-center align-middle rounded-default border border-hairline px-2 text-[13px] text-ink"
|
||||||
|
>
|
||||||
|
<KeyRound size={14} aria-hidden className="shrink-0" />
|
||||||
|
<Span className="font-bold whitespace-nowrap">{view.key}</Span>
|
||||||
|
<Span className="font-mono truncate select-all">{value ?? "••••••••"}</Span>
|
||||||
|
{view.can_reveal ? (
|
||||||
|
<>
|
||||||
|
<SecretChipButton
|
||||||
|
title={value === undefined ? `Reveal ${view.key}` : `Hide ${view.key}`}
|
||||||
|
icon={value === undefined ? Eye : EyeOff}
|
||||||
|
onClick={reveal}
|
||||||
|
/>
|
||||||
|
<SecretChipButton title={`Copy ${view.key}`} icon={copied ? Check : Copy} onClick={copy} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{error ? <Span className="text-[12px] text-ink-secondary whitespace-nowrap">{error}</Span> : null}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
import SecretRequestCard from "./secret-request-card";
|
||||||
|
|
||||||
|
const token: SecretRequestView = {
|
||||||
|
id: 4,
|
||||||
|
key: "GITHUB_TOKEN",
|
||||||
|
description: "GitHub personal access token with repo scope",
|
||||||
|
reason: "To list your repositories with gh.",
|
||||||
|
status: "pending",
|
||||||
|
bot_id: 6,
|
||||||
|
bot_name: "Ada",
|
||||||
|
env_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function render({ view }: { view: SecretRequestView }) {
|
||||||
|
return renderToString(<SecretRequestCard view={view} onDecided={() => {}} onDone={() => {}} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SecretRequestCard", () => {
|
||||||
|
test("asks for it in a masked input, with the bot's reason", () => {
|
||||||
|
const html = render({ view: token });
|
||||||
|
|
||||||
|
expect(html).toContain("GitHub personal access token with repo scope");
|
||||||
|
expect(html).toContain("To list your repositories with gh.");
|
||||||
|
expect(html).toContain(`type="password"`);
|
||||||
|
expect(html).toContain(">Save<");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("once answered, offers no input or buttons", () => {
|
||||||
|
const saved = render({ view: { ...token, status: "saved", env_id: 9 } });
|
||||||
|
|
||||||
|
expect(saved).not.toContain(`type="password"`);
|
||||||
|
expect(saved).not.toContain(">Save<");
|
||||||
|
expect(render({ view: { ...token, status: "declined" } })).toContain("Nothing was saved");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useContext, useState } from "react";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
import decideSecretRequest from "../(functions)/decide-secret-request";
|
||||||
|
import sendBotMessage from "../(functions)/send-bot-message";
|
||||||
|
import secretRequestMessage from "../(utils)/secret-request-message";
|
||||||
|
import MessageDetailsHeading from "./message-details-heading";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
view: SecretRequestView;
|
||||||
|
onDecided: (view: SecretRequestView) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card a user types a secret a bot asked for into. The value goes
|
||||||
|
* from here to the app only: it's saved encrypted as their personal secret
|
||||||
|
* for the bot, never put in a message, and the bot (and its model
|
||||||
|
* provider) only learns its key. Saving or declining tells the bot in chat.
|
||||||
|
*/
|
||||||
|
export default function SecretRequestCard({ view, onDecided, onDone }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [busy, setBusy] = useState<"save" | "decline">();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const open = view.status == "pending";
|
||||||
|
|
||||||
|
const decide = async (decision: "save" | "decline") => {
|
||||||
|
if (decision == "save" && !value) {
|
||||||
|
setError(`Enter the value first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const question =
|
||||||
|
decision == "save"
|
||||||
|
? `Save ${view.key} for ${view.bot_name}? Its runs for you get it on their computers; it's never shown to the bot.`
|
||||||
|
: `Decline? Nothing will be saved.`;
|
||||||
|
|
||||||
|
if (!window.confirm(question)) return;
|
||||||
|
|
||||||
|
setBusy(decision);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await decideSecretRequest({
|
||||||
|
request_id: view.id,
|
||||||
|
value: decision == "save" ? value : undefined,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!res?.success || !res.singleRes) {
|
||||||
|
setBusy(undefined);
|
||||||
|
setError(res?.msg || `Couldn't ${decision} it.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue("");
|
||||||
|
onDecided(res.singleRes);
|
||||||
|
|
||||||
|
await sendBotMessage({
|
||||||
|
bot_id: Number(pageProps?.bot?.id) || 0,
|
||||||
|
workspace_id: pageProps?.workspace?.id || null,
|
||||||
|
text: secretRequestMessage({ view: res.singleRes }),
|
||||||
|
media_ids: [],
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
setBusy(undefined);
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full gap-0! items-stretch">
|
||||||
|
<MessageDetailsHeading>What it is</MessageDetailsHeading>
|
||||||
|
<Span className="text-[13px] leading-5 text-ink break-words">{view.description}</Span>
|
||||||
|
<Span className="mt-1 text-[12px] leading-4 text-ink-secondary">{`Saved as ${view.key}, for your runs of ${view.bot_name} only.`}</Span>
|
||||||
|
|
||||||
|
<MessageDetailsHeading>Why (written by the bot)</MessageDetailsHeading>
|
||||||
|
<Span className="whitespace-pre-wrap text-[13px] leading-5 text-ink break-words">{view.reason}</Span>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<Stack className="mt-5 w-full gap-2! items-stretch">
|
||||||
|
<Input
|
||||||
|
id={`secret-request-${view.id}`}
|
||||||
|
title={view.key}
|
||||||
|
label={view.key}
|
||||||
|
showLabel
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Paste or type it here, not in the chat"
|
||||||
|
info="Encrypted before it's stored. The bot and its model never see it."
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Span className="mt-4 text-[13px] leading-5 text-ink">
|
||||||
|
{view.status == "saved" ? `Saved. You can replace or remove it in Settings, under Secrets.` : `Declined. Nothing was saved.`}
|
||||||
|
</Span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <Span className="mt-2 text-[13px] leading-5 text-ink">{error}</Span> : null}
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<Row className="mt-5 w-full gap-2! justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
title="Decline: nothing is saved"
|
||||||
|
loading={busy == "decline"}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onClick={() => decide("decline")}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
title={`Save ${view.key}`}
|
||||||
|
loading={busy == "save"}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onClick={() => decide("save")}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "@moduletrace/twui/elements/Modal";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import H2 from "@moduletrace/twui/layout/H2";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import useSecretRequest from "../(hooks)/use-secret-request";
|
||||||
|
import SecretRequestCard from "./secret-request-card";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
request_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusLabels = {
|
||||||
|
pending: "waiting for you",
|
||||||
|
saved: "saved",
|
||||||
|
declined: "declined",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret a bot asked for (`secretRequestRef`): a chip naming its key and
|
||||||
|
* where it stands, which opens the card the user types it into. Anyone it
|
||||||
|
* wasn't asked of sees its number only.
|
||||||
|
*/
|
||||||
|
export default function SecretRequestRefChip({ request_id }: Props) {
|
||||||
|
const { view, loading, setView } = useSecretRequest({ request_id });
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
return (
|
||||||
|
<Span className="font-bold">
|
||||||
|
{loading ? `secret request ${request_id}` : `secret request ${request_id} (not available)`}
|
||||||
|
</Span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = StatusLabels[view.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
target={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="smaller"
|
||||||
|
title={view.status == "pending" ? `Enter ${view.key}` : `${view.key}: ${status}`}
|
||||||
|
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<KeyRound size={14} aria-hidden className="shrink-0" />
|
||||||
|
{`${view.key} · ${status}`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
className="max-w-xl bg-paper p-6"
|
||||||
|
>
|
||||||
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
|
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{`${view.bot_name} asks for ${view.key}`}</H2>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{`Type it here rather than in the chat: what you write in the chat goes to the bot's model provider, this never does.`}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||||
|
<SecretRequestCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
view: SecretRequestView;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the chat tells the bot once the user answered its secret request,
|
||||||
|
* so it carries on. Only the key: the value never goes into a message.
|
||||||
|
*/
|
||||||
|
export default function secretRequestMessage({ view }: Params) {
|
||||||
|
if (view.status == "declined") return `I declined to give ${view.key} (secret request #${view.id}).`;
|
||||||
|
|
||||||
|
return `I saved ${view.key} (secret request #${view.id}). It's set as $${view.key} on your computers from your next run.`;
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ const Descriptions: Record<EnvScope["paradigm"], string> = {
|
|||||||
computer:
|
computer:
|
||||||
"For this computer only. Beats a workspace, loses to the bot's own.",
|
"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.",
|
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
|
||||||
|
user: "For one user's runs of one bot, set from the chat. Beats everything else.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import RowIconButton from "@/src/components/general/row-icon-button";
|
||||||
|
import type { PersonalSecret } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secret: PersonalSecret;
|
||||||
|
/**
|
||||||
|
* Whose it is, for the super admin's list of everyone's.
|
||||||
|
*/
|
||||||
|
show_user?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
/**
|
||||||
|
* Saves a new value; without it the row can only be removed.
|
||||||
|
*/
|
||||||
|
onReplace?: (params: { secret: PersonalSecret; value: string }) => Promise<boolean>;
|
||||||
|
onRemove: (params: { secret: PersonalSecret }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One personal secret: its key, what it's for, which bot (and whose), and
|
||||||
|
* never its value. Replace opens a masked input in place.
|
||||||
|
*/
|
||||||
|
export default function PersonalSecretRow({ secret, show_user, busy, onReplace, onRemove }: Props) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!value || !onReplace) return;
|
||||||
|
|
||||||
|
if (await onReplace({ secret, value })) {
|
||||||
|
setValue("");
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
className={twMerge(
|
||||||
|
"w-full gap-3! items-stretch py-3 px-4",
|
||||||
|
"border-0 border-b border-solid border-hairline last:border-b-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
|
||||||
|
<Stack className="gap-0.5! min-w-0">
|
||||||
|
<Span className="text-sm font-bold leading-5 text-ink truncate">{secret.key}</Span>
|
||||||
|
{secret.description ? (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">{secret.description}</Span>
|
||||||
|
) : null}
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{`${show_user ? `${secret.user_name}, for ` : "For "}${secret.bot_name}`}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
<Row className="gap-1! flex-nowrap shrink-0">
|
||||||
|
{onReplace ? (
|
||||||
|
<RowIconButton
|
||||||
|
icon={Pencil}
|
||||||
|
title={`Replace ${secret.key}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditing(!editing)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<RowIconButton
|
||||||
|
icon={Trash2}
|
||||||
|
title={`Remove ${secret.key}`}
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onRemove({ secret })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
{editing ? (
|
||||||
|
<Row className="w-full gap-2! flex-nowrap items-end">
|
||||||
|
<Input
|
||||||
|
id={`personal-secret-${secret.id}`}
|
||||||
|
title={`New value for ${secret.key}`}
|
||||||
|
label="New value"
|
||||||
|
showLabel
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
title="Cancel"
|
||||||
|
onClick={() => {
|
||||||
|
setValue("");
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="small" title={`Save ${secret.key}`} loading={busy} disabled={busy || !value} onClick={save}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { PersonalSecret } from "@/src/types";
|
||||||
|
import SettingsGroup from "../settings-group";
|
||||||
|
import SettingsList from "../settings-list";
|
||||||
|
import SettingsStatus from "../settings-status";
|
||||||
|
import PersonalSecretRow from "./personal-secret-row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
secrets: PersonalSecret[];
|
||||||
|
show_user?: boolean;
|
||||||
|
busy_id?: number;
|
||||||
|
status?: { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
onReplace?: (params: { secret: PersonalSecret; value: string }) => Promise<boolean>;
|
||||||
|
onRemove: (params: { secret: PersonalSecret }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of personal secrets (set from the chat), values never shown: the
|
||||||
|
* user's own on the Secrets page, everyone's on the Environment page.
|
||||||
|
*/
|
||||||
|
export default function PersonalSecretsGroup({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
secrets,
|
||||||
|
show_user,
|
||||||
|
busy_id,
|
||||||
|
status,
|
||||||
|
onReplace,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<SettingsGroup title={title} description={description}>
|
||||||
|
<SettingsList is_empty={!secrets.length} empty="No secrets saved yet.">
|
||||||
|
{secrets.map((secret) => (
|
||||||
|
<PersonalSecretRow
|
||||||
|
key={secret.id}
|
||||||
|
secret={secret}
|
||||||
|
show_user={show_user}
|
||||||
|
busy={busy_id == secret.id}
|
||||||
|
onReplace={onReplace}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsList>
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,17 @@ import { useContext, useState } from "react";
|
|||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import type { EnvsMutationResult, EnvsOverviewPayload } from "@/src/types";
|
import type { EnvsMutationResult, EnvsOverviewPayload } from "@/src/types";
|
||||||
import EnvsGroup from "../../(partials)/envs/envs-group";
|
import EnvsGroup from "../../(partials)/envs/envs-group";
|
||||||
|
import postEnvApi from "../../(functions)/post-env-api";
|
||||||
|
import PersonalSecretsGroup from "../../(partials)/secrets/personal-secrets-group";
|
||||||
import EnvScopeSelect from "../(partials)/env-scope-select";
|
import EnvScopeSelect from "../(partials)/env-scope-select";
|
||||||
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 bot or a computer), then that scope's
|
* Pick where (general, a workspace, a bot or a computer), then that scope's
|
||||||
* envs. General also lists the app settings. Every change saves at once and
|
* envs. General also lists the app settings. Below, every user's personal
|
||||||
* refreshes the page data.
|
* secrets (set from the chat), which can only be removed here. Every change
|
||||||
|
* saves at once and refreshes the page data.
|
||||||
*/
|
*/
|
||||||
export default function EnvironmentSettingsSection() {
|
export default function EnvironmentSettingsSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
@@ -20,6 +23,7 @@ export default function EnvironmentSettingsSection() {
|
|||||||
* Remounts the lists when the data changes under them.
|
* Remounts the lists when the data changes under them.
|
||||||
*/
|
*/
|
||||||
const [version, setVersion] = useState(0);
|
const [version, setVersion] = useState(0);
|
||||||
|
const [removing, setRemoving] = useState<number>();
|
||||||
|
|
||||||
if (!overview) return null;
|
if (!overview) return null;
|
||||||
|
|
||||||
@@ -60,6 +64,23 @@ export default function EnvironmentSettingsSection() {
|
|||||||
onChanged={changed}
|
onChanged={changed}
|
||||||
onError={failed}
|
onError={failed}
|
||||||
/>
|
/>
|
||||||
|
<PersonalSecretsGroup
|
||||||
|
title="Personal secrets"
|
||||||
|
description="What users gave their bots from the chat, each for one user's runs of one bot. Only its user can see or replace one; you can remove it."
|
||||||
|
secrets={overview.personal || []}
|
||||||
|
show_user
|
||||||
|
busy_id={removing}
|
||||||
|
onRemove={async ({ secret }) => {
|
||||||
|
if (!window.confirm(`Remove ${secret.user_name}'s ${secret.key} for ${secret.bot_name}?`)) return;
|
||||||
|
|
||||||
|
setRemoving(secret.id);
|
||||||
|
const res = await postEnvApi({ path: "delete-env", body: { env_id: secret.id } }).catch(() => null);
|
||||||
|
setRemoving(undefined);
|
||||||
|
|
||||||
|
if (res?.success && res.singleRes) changed(res.singleRes, res.msg || "Removed.");
|
||||||
|
else failed(res?.msg || "Couldn't remove it.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useContext, useState } from "react";
|
||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { PersonalSecret } from "@/src/types";
|
||||||
|
import PersonalSecretsGroup from "../../(partials)/secrets/personal-secrets-group";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's own personal secrets, with Replace and Remove. Every change
|
||||||
|
* saves at once and refreshes the list.
|
||||||
|
*/
|
||||||
|
export default function SecretsSettingsSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [secrets, setSecrets] = useState<PersonalSecret[]>(pageProps?.personal_secrets || []);
|
||||||
|
const [busy_id, setBusyId] = useState<number>();
|
||||||
|
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg?: string }>();
|
||||||
|
|
||||||
|
const post = async ({ path, body }: { path: "save-secret" | "delete-secret"; body: Record<string, unknown> }) => {
|
||||||
|
const res = await fetchApi<Record<string, unknown>, APIResponseObject<PersonalSecret[]>>(
|
||||||
|
`/api/admin/secrets/${path}`,
|
||||||
|
{ method: "POST", body },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
|
if (res?.success && res.singleRes) {
|
||||||
|
setSecrets(res.singleRes);
|
||||||
|
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({ error: true, msg: res?.msg || "Couldn't save it." });
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PersonalSecretsGroup
|
||||||
|
title="Your secrets"
|
||||||
|
description="Each one is for one bot and only your runs of it. Bots ask for them with a card in the chat, never in a message."
|
||||||
|
secrets={secrets}
|
||||||
|
busy_id={busy_id}
|
||||||
|
status={status}
|
||||||
|
onReplace={async ({ secret, value }) => {
|
||||||
|
if (!window.confirm(`Replace ${secret.key} for ${secret.bot_name}?`)) return false;
|
||||||
|
|
||||||
|
setBusyId(secret.id);
|
||||||
|
const ok = await post({ path: "save-secret", body: { env_id: secret.id, value } });
|
||||||
|
setBusyId(undefined);
|
||||||
|
|
||||||
|
return ok;
|
||||||
|
}}
|
||||||
|
onRemove={async ({ secret }) => {
|
||||||
|
if (!window.confirm(`Remove ${secret.key} for ${secret.bot_name}? Its computers drop it on their next run.`)) return;
|
||||||
|
|
||||||
|
setBusyId(secret.id);
|
||||||
|
await post({ path: "delete-secret", body: { env_id: secret.id } });
|
||||||
|
setBusyId(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PagePropsType } from "@/src/types";
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabPersonalSecrets from "@/src/functions/backend/db/envs/grab-personal-secrets";
|
||||||
|
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every user's own personal secrets: keys only, never values.
|
||||||
|
*/
|
||||||
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
|
const settings = await grabSettingsServerProps(params);
|
||||||
|
|
||||||
|
if (!settings.props) return settings;
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req: params.req });
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...settings.props,
|
||||||
|
personal_secrets: user?.id ? await grabPersonalSecrets({ user_id: user.id }) : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import SecretsSettingsSection from "./(sections)/secrets-settings-section";
|
||||||
|
import SettingsShell from "../(partials)/settings-shell";
|
||||||
|
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||||
|
|
||||||
|
export default function AdminSettingsSecretsPage() {
|
||||||
|
return (
|
||||||
|
<SettingsShell
|
||||||
|
title="Secrets"
|
||||||
|
description="Passwords, tokens and keys you gave your bots from the chat, or they saved for you. Bots use them by name and never see them."
|
||||||
|
>
|
||||||
|
<SecretsSettingsSection />
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meta = settingsPageMeta({ label: "Secrets" });
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import answerSecretRequest from "@/src/functions/backend/db/secret-requests/answer-secret-request";
|
||||||
|
import grabSecretRequestView from "@/src/functions/backend/db/secret-requests/grab-secret-request-view";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { AnswerSecretRequestSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session user typed the secret a bot asked them for into its card:
|
||||||
|
* saved as their personal secret for that bot. Answers with the request
|
||||||
|
* as it now stands, never the value. Logged by key only.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<SecretRequestView>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request_id, value } = parseWithSchema({ schema: AnswerSecretRequestSchema, data: body });
|
||||||
|
const request = await answerSecretRequest({ request_id, user_id: user.id, value });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Secret saved",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} saved ${request.key} for bot #${request.bot_id}, as bot #${request.bot_id} asked (request #${request.id}).`,
|
||||||
|
metadata: { request_id: request.id, key: request.key, bot_id: request.bot_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Saved ${request.key}.`,
|
||||||
|
singleRes: await grabSecretRequestView({ request_id, user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import declineSecretRequest from "@/src/functions/backend/db/secret-requests/decline-secret-request";
|
||||||
|
import grabSecretRequestView from "@/src/functions/backend/db/secret-requests/grab-secret-request-view";
|
||||||
|
import { SecretRequestIdSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session user declined a secret a bot asked them for: nothing saved.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<SecretRequestView>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request_id } = parseWithSchema({ schema: SecretRequestIdSchema, data: body });
|
||||||
|
|
||||||
|
await declineSecretRequest({ request_id, user_id: user.id });
|
||||||
|
|
||||||
|
return { success: true, singleRes: await grabSecretRequestView({ request_id, user_id: user.id }) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabSecretRequestView from "@/src/functions/backend/db/secret-requests/grab-secret-request-view";
|
||||||
|
import { SecretRequestIdSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import type { SecretRequestView } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET ?request_id=`: a secret a bot asked the session user for, as its
|
||||||
|
* chip and card show it. Nobody else reads it.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<SecretRequestView>> = async ({ req, query }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "GET") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request_id } = parseWithSchema({ schema: SecretRequestIdSchema, data: query });
|
||||||
|
|
||||||
|
return { success: true, singleRes: await grabSecretRequestView({ request_id, user_id: user.id }) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import deleteUserSecret from "@/src/functions/backend/db/envs/delete-user-secret";
|
||||||
|
import grabPersonalSecrets from "@/src/functions/backend/db/envs/grab-personal-secrets";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SecretIdSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import type { PersonalSecret } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one of the session user's own personal secrets. Answers with
|
||||||
|
* their fresh list.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<PersonalSecret[]>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env_id } = parseWithSchema({ schema: SecretIdSchema, data: body });
|
||||||
|
const key = await deleteUserSecret({ user_id: user.id, env_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Secret removed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} removed their secret ${key}.`,
|
||||||
|
metadata: { env_id, key },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, msg: `Removed ${key}.`, singleRes: await grabPersonalSecrets({ user_id: user.id }) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabRevealableEnv from "@/src/functions/backend/db/secrets/grab-revealable-env";
|
||||||
|
import { SecretIdSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import type { SecretRefView } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET ?env_id=`: a secret a bot showed in the chat (`[secret:ID]`), as its
|
||||||
|
* chip shows it before anyone asks for the value: the key, and whether
|
||||||
|
* the session user may reveal it. Never the value.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<SecretRefView>> = async ({ req, query }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "GET") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env_id } = parseWithSchema({ schema: SecretIdSchema, data: query });
|
||||||
|
const { env, can_reveal } = await grabRevealableEnv({ env_id, user_id: user.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
singleRes: { id: env.id, key: env.key, description: env.description, can_reveal },
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabRevealableEnv from "@/src/functions/backend/db/secrets/grab-revealable-env";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SecretIdSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret's value, for its chip's Reveal or Copy: only to whom
|
||||||
|
* `canRevealEnv` allows. This is the one way a value leaves the app for a
|
||||||
|
* browser from the chat; it never goes into a page, a message or the
|
||||||
|
* history. Logged by key, never by value.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<{ value: string }>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env_id } = parseWithSchema({ schema: SecretIdSchema, data: body });
|
||||||
|
const { env, can_reveal } = await grabRevealableEnv({ env_id, user_id: user.id });
|
||||||
|
|
||||||
|
if (!can_reveal) {
|
||||||
|
throw new AppError(
|
||||||
|
env.paradigm == "user" ? `You no longer have this bot.` : `Only a super admin can reveal this.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Secret revealed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} revealed ${env.key} (${env.paradigm} env #${env.id}) from the chat.`,
|
||||||
|
metadata: { env_id: env.id, key: env.key, paradigm: env.paradigm },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, singleRes: { value: env.value } };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabAllEnvs from "@/src/functions/backend/db/envs/grab-all-envs";
|
||||||
|
import grabPersonalSecrets from "@/src/functions/backend/db/envs/grab-personal-secrets";
|
||||||
|
import saveUserSecret from "@/src/functions/backend/db/envs/save-user-secret";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SaveUserSecretSchema } from "@/src/schemas/secret-request-schema";
|
||||||
|
import type { PersonalSecret } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the value of one of the session user's own personal secrets.
|
||||||
|
* Answers with their fresh list. Logged by key, never by value.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<PersonalSecret[]>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env_id, value } = parseWithSchema({ schema: SaveUserSecretSchema, data: body });
|
||||||
|
const env = (await grabAllEnvs()).find(
|
||||||
|
(e) => e.id == env_id && e.paradigm == "user" && e.user_id == Number(user.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!env?.bot_id) throw new AppError(`That secret no longer exists.`);
|
||||||
|
|
||||||
|
await saveUserSecret({ user_id: user.id, bot_id: env.bot_id, key: env.key, value });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Secret saved",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} replaced ${env.key} for bot #${env.bot_id}.`,
|
||||||
|
metadata: { env_id, key: env.key, bot_id: env.bot_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, msg: `Saved ${env.key}.`, singleRes: await grabPersonalSecrets({ user_id: user.id }) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,7 +9,12 @@ export const SaveEnvRequestSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
value: z.string().max(20000, "Keep values under 20,000 characters").optional(),
|
value: z.string().max(20000, "Keep values under 20,000 characters").optional(),
|
||||||
description: z.string().trim().max(500).optional(),
|
description: z.string().trim().max(500).optional(),
|
||||||
paradigm: z.enum(EnvParadigms.map((p) => p.value) as [EnvParadigm, ...EnvParadigm[]]),
|
/**
|
||||||
|
* Personal envs (`user`) are set from the chat, never here.
|
||||||
|
*/
|
||||||
|
paradigm: z.enum(
|
||||||
|
EnvParadigms.filter((p) => p.value != "user").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(),
|
bot_id: z.coerce.number().int().positive().nullable().optional(),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const SecretRequestIdSchema = z.object({
|
||||||
|
request_id: z.coerce.number().int().positive("Pick a request"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AnswerSecretRequestSchema = z.object({
|
||||||
|
request_id: z.coerce.number().int().positive("Pick a request"),
|
||||||
|
value: z.string().min(1, "Enter a value").max(20000, "Keep values under 20,000 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SecretIdSchema = z.object({
|
||||||
|
env_id: z.coerce.number().int().positive("Pick a secret"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SaveUserSecretSchema = z.object({
|
||||||
|
env_id: z.coerce.number().int().positive("Pick a secret"),
|
||||||
|
value: z.string().min(1, "Enter a value").max(20000, "Keep values under 20,000 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SecretRequestIdRequest = z.infer<typeof SecretRequestIdSchema>;
|
||||||
|
export type AnswerSecretRequestRequest = z.infer<typeof AnswerSecretRequestSchema>;
|
||||||
|
export type SecretIdRequest = z.infer<typeof SecretIdSchema>;
|
||||||
|
export type SaveUserSecretRequest = z.infer<typeof SaveUserSecretSchema>;
|
||||||
+53
-1
@@ -31,7 +31,7 @@ import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "./sql-joins";
|
|||||||
import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/form/imageInputToBase64";
|
import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/form/imageInputToBase64";
|
||||||
import type { HarnessKind } from "../dict/harness-dict";
|
import type { HarnessKind } from "../dict/harness-dict";
|
||||||
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
||||||
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
|
import type { EnvParadigm, KnownEnv, SecretRequestStatus } from "../dict/env-dict";
|
||||||
import type { ContainerStatus } from "../dict/computer-dict";
|
import type { ContainerStatus } from "../dict/computer-dict";
|
||||||
import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
|
import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
|
||||||
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
|
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
|
||||||
@@ -601,6 +601,50 @@ export type PublicEnv = {
|
|||||||
* Somewhere envs can be set: general, or one of the user's workspaces,
|
* Somewhere envs can be set: general, or one of the user's workspaces,
|
||||||
* computers or bots.
|
* computers or bots.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* A user's personal secret for one bot (an env with paradigm \`user\`), as
|
||||||
|
* settings pages list it: never its value.
|
||||||
|
*/
|
||||||
|
export type PersonalSecret = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
bot_id: number;
|
||||||
|
bot_name: string;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret a bot asked the user for (\`[secret-request:ID]\`), as its chip
|
||||||
|
* and card show it. Never holds a value.
|
||||||
|
*/
|
||||||
|
export type SecretRequestView = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
reason: string;
|
||||||
|
status: SecretRequestStatus;
|
||||||
|
bot_id: number;
|
||||||
|
bot_name: string;
|
||||||
|
/**
|
||||||
|
* The personal secret it saved, once saved.
|
||||||
|
*/
|
||||||
|
env_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A secret ref (\`[secret:ID]\`) as its chip shows it before anyone asks
|
||||||
|
* for the value: the key, and whether this viewer may reveal it.
|
||||||
|
*/
|
||||||
|
export type SecretRefView = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
can_reveal: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvScope = {
|
export type EnvScope = {
|
||||||
paradigm: EnvParadigm;
|
paradigm: EnvParadigm;
|
||||||
id: number | null;
|
id: number | null;
|
||||||
@@ -984,6 +1028,10 @@ export type EnvsOverviewPayload = {
|
|||||||
scopes: EnvScope[];
|
scopes: EnvScope[];
|
||||||
envs: PublicEnv[];
|
envs: PublicEnv[];
|
||||||
known: KnownEnvStatus[];
|
known: KnownEnvStatus[];
|
||||||
|
/**
|
||||||
|
* Every user's personal secrets, set from the chat.
|
||||||
|
*/
|
||||||
|
personal: PersonalSecret[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1556,6 +1604,10 @@ export type PagePropsType = {
|
|||||||
harness_overview?: HarnessOverviewPayload | null;
|
harness_overview?: HarnessOverviewPayload | null;
|
||||||
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
||||||
envs_overview?: EnvsOverviewPayload | null;
|
envs_overview?: EnvsOverviewPayload | null;
|
||||||
|
/**
|
||||||
|
* The viewer's own personal secrets (the Secrets settings page).
|
||||||
|
*/
|
||||||
|
personal_secrets?: PersonalSecret[] | null;
|
||||||
/**
|
/**
|
||||||
* What bots keep on disk: the per-folder quota and its usage (the
|
* What bots keep on disk: the per-folder quota and its usage (the
|
||||||
* General settings page).
|
* General settings page).
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import checkSecretKey from "./check-secret-key";
|
||||||
|
|
||||||
|
describe("checkSecretKey", () => {
|
||||||
|
test("keeps a shell variable name, trimmed", () => {
|
||||||
|
expect(checkSecretKey({ key: " GITHUB_TOKEN " })).toBe("GITHUB_TOKEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses names a shell can't take", () => {
|
||||||
|
expect(() => checkSecretKey({ key: "1TOKEN" })).toThrow();
|
||||||
|
expect(() => checkSecretKey({ key: "MY-TOKEN" })).toThrow();
|
||||||
|
expect(() => checkSecretKey({ key: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses the app's own settings and process-only keys", () => {
|
||||||
|
expect(() => checkSecretKey({ key: "ENCRYPTION_KEY" })).toThrow();
|
||||||
|
expect(() => checkSecretKey({ key: "VAPID_PRIVATE_KEY" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { EnvDict, EnvKeyRegex, ProcessOnlyEnvKeys } from "@/src/dict/env-dict";
|
||||||
|
import AppError from "./app-error";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key a personal secret is saved under, trimmed: a shell variable
|
||||||
|
* name, and never one of the app's own settings or process-only keys.
|
||||||
|
* Throws why otherwise.
|
||||||
|
*/
|
||||||
|
export default function checkSecretKey({ key }: Params) {
|
||||||
|
const final_key = key.trim();
|
||||||
|
|
||||||
|
if (!EnvKeyRegex.test(final_key)) {
|
||||||
|
throw new AppError(`Use letters, digits and underscores for the key, starting with a letter or underscore.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(ProcessOnlyEnvKeys as readonly string[]).includes(final_key) ||
|
||||||
|
EnvDict.some((env) => env.key == final_key)
|
||||||
|
) {
|
||||||
|
throw new AppError(`${final_key} is one of the app's own settings. Pick another key.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return final_key;
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ describe("exportMessageText", () => {
|
|||||||
).toBe("@Ada and a bot in #Launch on computer 4, see file 12 and `seo/raw`. (take over computer 4, browser)");
|
).toBe("@Ada and a bot in #Launch on computer 4, see file 12 and `seo/raw`. (take over computer 4, browser)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("words secrets and secret requests by number, never a value", () => {
|
||||||
|
expect(exportMessageText({ text: "Here: [secret:12]. [secret-request:4]", ...names, heading_offset: 0 })).toBe(
|
||||||
|
"Here: (secret 12, hidden). (secret request 4)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("leaves refs that are already links alone", () => {
|
test("leaves refs that are already links alone", () => {
|
||||||
expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)");
|
expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* A message's markdown as it reads outside the app: its refs in words
|
* A message's markdown as it reads outside the app: its refs in words
|
||||||
* (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as
|
* (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as
|
||||||
* "computer 3", a folder as its path) and its headings dropped by
|
* "computer 3", a folder as its path, a secret by number only, never its
|
||||||
|
* value) and its headings dropped by
|
||||||
* `heading_offset`. Code blocks are left exactly as they are.
|
* `heading_offset`. Code blocks are left exactly as they are.
|
||||||
*/
|
*/
|
||||||
export default function exportMessageText({
|
export default function exportMessageText({
|
||||||
@@ -82,6 +83,8 @@ function wordRefs({
|
|||||||
.replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)")
|
.replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)")
|
||||||
.replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1")
|
.replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1")
|
||||||
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
|
.replace(/\[hook-proposal:(\d+)\](?!\()/g, "hook proposal $1")
|
||||||
|
.replace(/\[secret:(\d+)\](?!\()/g, "(secret $1, hidden)")
|
||||||
|
.replace(/\[secret-request:(\d+)\](?!\()/g, "(secret request $1)")
|
||||||
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
|
.replace(/\[file:(\d+)\](?!\()/g, "file $1")
|
||||||
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
|
.replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`")
|
||||||
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
|
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder");
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import computerRef from "./computer-ref";
|
|||||||
import linkComputerRefs from "./link-computer-refs";
|
import linkComputerRefs from "./link-computer-refs";
|
||||||
import hookProposalRef from "./hook-proposal-ref";
|
import hookProposalRef from "./hook-proposal-ref";
|
||||||
import proposalRef from "./proposal-ref";
|
import proposalRef from "./proposal-ref";
|
||||||
|
import secretRef from "./secret-ref";
|
||||||
|
import secretRequestRef from "./secret-request-ref";
|
||||||
import takeOverRef from "./take-over-ref";
|
import takeOverRef from "./take-over-ref";
|
||||||
|
|
||||||
describe("linkComputerRefs", () => {
|
describe("linkComputerRefs", () => {
|
||||||
@@ -34,6 +36,12 @@ describe("linkComputerRefs", () => {
|
|||||||
).toBe("[hook proposal 4](#hook-proposal-4) and [proposal 4](#proposal-4)");
|
).toBe("[hook proposal 4](#hook-proposal-4) and [proposal 4](#proposal-4)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("links secrets and secret requests, never confusing the two", () => {
|
||||||
|
expect(
|
||||||
|
linkComputerRefs({ text: `${secretRef({ id: 12 })} and ${secretRequestRef({ id: 4 })}` }),
|
||||||
|
).toBe("[secret 12](#secret-ref-12) and [secret request 4](#secret-request-4)");
|
||||||
|
});
|
||||||
|
|
||||||
test("links take-over requests", () => {
|
test("links take-over requests", () => {
|
||||||
expect(
|
expect(
|
||||||
linkComputerRefs({
|
linkComputerRefs({
|
||||||
|
|||||||
@@ -51,12 +51,26 @@ export const ProposalRefHrefPrefix = "#proposal-";
|
|||||||
*/
|
*/
|
||||||
export const HookProposalRefHrefPrefix = "#hook-proposal-";
|
export const HookProposalRefHrefPrefix = "#hook-proposal-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link target a secret ref (`secretRef`) becomes, e.g. `#secret-ref-12`.
|
||||||
|
* The chat renders it as a masked chip with Reveal and Copy.
|
||||||
|
*/
|
||||||
|
export const SecretRefHrefPrefix = "#secret-ref-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The link target a secret request ref (`secretRequestRef`) becomes, e.g.
|
||||||
|
* `#secret-request-4`. The chat renders it as a chip that opens the card
|
||||||
|
* the user types the secret into.
|
||||||
|
*/
|
||||||
|
export const SecretRequestRefHrefPrefix = "#secret-request-";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns every computer ref (`[computer:3]`) and take-over ref
|
* Turns every computer ref (`[computer:3]`) and take-over ref
|
||||||
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
|
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
|
||||||
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
||||||
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
|
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
|
||||||
* proposal ref (`[hook-proposal:4]`) in a message into a markdown link, so it
|
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
|
||||||
|
* request ref (`[secret-request:4]`) in a message into a markdown link, so it
|
||||||
* survives markdown rendering as a link the chat can swap for a button,
|
* survives markdown rendering as a link the chat can swap for a button,
|
||||||
* chip or download.
|
* chip or download.
|
||||||
*
|
*
|
||||||
@@ -87,6 +101,11 @@ export default function linkComputerRefs({ text }: { text: string }) {
|
|||||||
/\[hook-proposal:(\d+)\](?!\()/g,
|
/\[hook-proposal:(\d+)\](?!\()/g,
|
||||||
(_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`,
|
(_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`,
|
||||||
)
|
)
|
||||||
|
.replace(/\[secret:(\d+)\](?!\()/g, (_, id: string) => `[secret ${id}](${SecretRefHrefPrefix}${id})`)
|
||||||
|
.replace(
|
||||||
|
/\[secret-request:(\d+)\](?!\()/g,
|
||||||
|
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
|
||||||
|
)
|
||||||
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
||||||
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
|
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
|
||||||
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
|
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import canRevealEnv from "./can-reveal-env";
|
||||||
|
|
||||||
|
describe("canRevealEnv", () => {
|
||||||
|
const personal = { paradigm: "user", user_id: 4 };
|
||||||
|
const general = { paradigm: "general", user_id: null };
|
||||||
|
|
||||||
|
test("a personal secret only to its own user, while they reach the bot", () => {
|
||||||
|
expect(canRevealEnv({ env: personal, viewer_id: 4, role: "member", reaches_bot: true })).toBe(true);
|
||||||
|
expect(canRevealEnv({ env: personal, viewer_id: 4, role: "member", reaches_bot: false })).toBe(false);
|
||||||
|
expect(canRevealEnv({ env: personal, viewer_id: 1, role: "super_admin", reaches_bot: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("any other env only to a super admin", () => {
|
||||||
|
expect(canRevealEnv({ env: general, viewer_id: 1, role: "super_admin", reaches_bot: true })).toBe(true);
|
||||||
|
expect(canRevealEnv({ env: general, viewer_id: 2, role: "admin", reaches_bot: true })).toBe(false);
|
||||||
|
expect(canRevealEnv({ env: { paradigm: "bot", user_id: null }, viewer_id: 3, role: "member", reaches_bot: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { CrewRole } from "./crew-role";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
env: { paradigm: string; user_id: number | null };
|
||||||
|
viewer_id: number | string;
|
||||||
|
role: CrewRole;
|
||||||
|
/**
|
||||||
|
* Whether the viewer still reaches the env's bot (for a personal one).
|
||||||
|
*/
|
||||||
|
reaches_bot: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may see a secret's value in the chat (`[secret:ID]`): a personal
|
||||||
|
* one only its own user, while they still reach its bot; any other env
|
||||||
|
* only a super admin, as on the Environment page. A bot never may: the
|
||||||
|
* value only ever goes to the viewer's browser.
|
||||||
|
*/
|
||||||
|
export default function canRevealEnv({ env, viewer_id, role, reaches_bot }: Params) {
|
||||||
|
if (env.paradigm == "user") return env.user_id == Number(viewer_id) && reaches_bot;
|
||||||
|
|
||||||
|
return role == "super_admin";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
type Params = {
|
||||||
|
id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a bot shows the user a secret without ever holding its value:
|
||||||
|
* `[secret:12]`, the id of the env it's saved as. The chat shows it as a
|
||||||
|
* masked chip whose Reveal and Copy fetch the value for whoever may see
|
||||||
|
* it (`canRevealEnv`).
|
||||||
|
*/
|
||||||
|
export default function secretRef({ id }: Params) {
|
||||||
|
return `[secret:${id}]`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
type Params = {
|
||||||
|
id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a bot names a secret it asked the user for: `[secret-request:4]`.
|
||||||
|
* The chat shows it as a chip that opens the card the user types it
|
||||||
|
* into, so it never goes through the chat or the model.
|
||||||
|
*/
|
||||||
|
export default function secretRequestRef({ id }: Params) {
|
||||||
|
return `[secret-request:${id}]`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user