From 997de9c5a81a1745be9f15c3075f6a2b915cd1f9 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Fri, 4 Sep 2026 10:44:29 +0800 Subject: [PATCH 1/3] fix(platform): remove the personal Environment page nothing injected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings > Environment told users their variables and secrets are "injected into all of your sandboxes". Nothing ever did: `resolveUserEnvForInjection` had one definition and no caller, and the only readers of `app.sandbox_user_env` were the page's own CRUD module, its three `/api/app/sandbox/user-env` routes, and the itest lane. No sandbox session, agent turn, automation script or run_code lane touched the rows. Remove the promise instead of building the feature: the route, feature folder, rail/menu entries, contract + adapter rows, the backend module, constants and routes, the locale keys in en/de/fr, the itest lane, the docs-screenshot seed step and the manual test-plan rows. The shared EnvVarListEditor stays (project secrets use it). The table stays too — applied schema is never dropped — with a deprecation note on the migration header. --- .../env/use-env-editor-controller.ts | 51 ----- .../settings/components/settings-page.tsx | 3 +- .../settings/components/settings-rail.tsx | 1 - .../components/use-settings-menu-groups.ts | 6 - .../user-env/components/user-env-settings.tsx | 65 ------- .../settings/user-env/hooks/mutations.ts | 21 --- .../settings/user-env/hooks/queries.ts | 13 -- .../app/lib/backend/contract/sandbox.ts | 26 --- services/platform/app/lib/backend/settings.ts | 44 ----- services/platform/app/routeTree.gen.ts | 22 --- .../dashboard/$id/settings/environment.tsx | 24 --- .../backend/core/agent_secrets/constants.ts | 4 +- .../core/sandbox/user_env_constants.test.ts | 82 --------- .../core/sandbox/user_env_constants.ts | 69 ------- .../db/migrations/0050_sandbox_user_env.sql | 5 + .../backend/domains/sandbox/routes.ts | 48 ----- .../backend/domains/sandbox/user-env.ts | 174 ------------------ .../platform/backend/integration-check.ts | 56 ------ services/platform/messages/de.yml | 16 -- services/platform/messages/en.yml | 12 -- services/platform/messages/fr.yml | 14 -- .../tests/docs-screenshots/demo-content.ts | 23 --- .../tests/docs-screenshots/seed-demo-org.ts | 47 ----- services/platform/tests/manual/SETUP.md | 1 - services/platform/tests/manual/settings.md | 8 +- 25 files changed, 10 insertions(+), 825 deletions(-) delete mode 100644 services/platform/app/components/env/use-env-editor-controller.ts delete mode 100644 services/platform/app/features/settings/user-env/components/user-env-settings.tsx delete mode 100644 services/platform/app/features/settings/user-env/hooks/mutations.ts delete mode 100644 services/platform/app/features/settings/user-env/hooks/queries.ts delete mode 100644 services/platform/app/routes/dashboard/$id/settings/environment.tsx delete mode 100644 services/platform/backend/core/sandbox/user_env_constants.test.ts delete mode 100644 services/platform/backend/core/sandbox/user_env_constants.ts delete mode 100644 services/platform/backend/domains/sandbox/user-env.ts diff --git a/services/platform/app/components/env/use-env-editor-controller.ts b/services/platform/app/components/env/use-env-editor-controller.ts deleted file mode 100644 index 3a7c724a77..0000000000 --- a/services/platform/app/components/env/use-env-editor-controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { useCallback, useMemo, useState } from 'react'; - -import type { EditorController } from '@/app/components/ui/editor/types'; - -import type { EnvEditorState } from './env-var-list-editor'; - -const EMPTY_KEYS: ReadonlySet = new Set(); - -/** - * Bridges `EnvVarListEditor`'s external-save state (`onEditorState`) to the - * unified `EditorController` contract, so env surfaces dock Save/Discard in - * the same header cluster as every other editor — register the returned - * controller via `useRegisterActiveEditor`, or compose it with a form editor - * (`composeEditors`) on pages that host both. - * - * `dirtyKey` is the top-level key reported in `dirtyKeys` while dirty — tab - * shells intersect it with a tab's declared keys to render the per-tab dot. - */ -export function useEnvEditorController(dirtyKey = 'environment'): { - controller: EditorController; - onEditorState: (state: EnvEditorState) => void; -} { - const [state, setState] = useState(null); - - const controller = useMemo( - () => ({ - isDirty: state?.isDirty ?? false, - isSaving: state?.isSaving ?? false, - isValid: true, - // No report yet = the editor hasn't mounted/loaded — keep Save disabled. - isLoading: state?.isLoading ?? true, - dirtyKeys: state?.isDirty ? new Set([dirtyKey]) : EMPTY_KEYS, - save: async () => { - await state?.save(); - }, - reset: () => { - state?.reset(); - }, - }), - [state, dirtyKey], - ); - - const onEditorState = useCallback( - (next: EnvEditorState) => setState(next), - [], - ); - - return { controller, onEditorState }; -} diff --git a/services/platform/app/features/settings/components/settings-page.tsx b/services/platform/app/features/settings/components/settings-page.tsx index 016408ad9f..82d98c517a 100644 --- a/services/platform/app/features/settings/components/settings-page.tsx +++ b/services/platform/app/features/settings/components/settings-page.tsx @@ -24,8 +24,7 @@ import { cn } from '@/lib/utils/cn'; * matched by asking whether a child CONTAINS a section; * • a wrapper holding a section header plus its table: those children are * not sections, so they get no line between them — the stray divider that - * appeared under Teams / Skills / Sandboxes / Branding / Trash and the - * environment page. + * appeared under Teams / Skills / Sandboxes / Branding / Trash. * * Dialogs and other children that render nothing where they sit never match * either rule, so a page can no longer end on a divider with empty space. diff --git a/services/platform/app/features/settings/components/settings-rail.tsx b/services/platform/app/features/settings/components/settings-rail.tsx index b5435e3784..294b932d51 100644 --- a/services/platform/app/features/settings/components/settings-rail.tsx +++ b/services/platform/app/features/settings/components/settings-rail.tsx @@ -90,7 +90,6 @@ export function SettingsRail({ { kind: 'leaf', labelKey: 'account', path: 'account' }, { kind: 'leaf', labelKey: 'personalization', path: 'personalization' }, { kind: 'leaf', labelKey: 'notifications', path: 'notifications' }, - { kind: 'leaf', labelKey: 'environment', path: 'environment' }, ]; if (!showAccountTab) personal.shift(); diff --git a/services/platform/app/features/settings/components/use-settings-menu-groups.ts b/services/platform/app/features/settings/components/use-settings-menu-groups.ts index 5dcd8daf4c..fdcfa89644 100644 --- a/services/platform/app/features/settings/components/use-settings-menu-groups.ts +++ b/services/platform/app/features/settings/components/use-settings-menu-groups.ts @@ -15,7 +15,6 @@ import { User, Users, UsersRound, - Variable, type LucideIcon, } from 'lucide-react'; import { useMemo } from 'react'; @@ -64,11 +63,6 @@ export function useSettingsMenuGroups( icon: Bell, path: 'notifications', }, - { - key: 'environment', - icon: Variable, - path: 'environment', - }, ]; // Order mirrors the desktop rail: who we are (organization, teams, diff --git a/services/platform/app/features/settings/user-env/components/user-env-settings.tsx b/services/platform/app/features/settings/user-env/components/user-env-settings.tsx deleted file mode 100644 index a79a0b25bc..0000000000 --- a/services/platform/app/features/settings/user-env/components/user-env-settings.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -/** - * Personal (per-user) env/secret editor, backed by the `userEnv` store. Renders - * the SAME shared `EnvVarListEditor` the agent + workflow/automation env editors - * use, inside the canonical settings chrome (`SettingsPage` + `SettingsSection`). - * Save/Discard docks in the settings header via the active-editor registry — - * like every other settings page — instead of an in-content Save button. - */ -import { Skeletonize } from '@tale/ui/skeleton-context'; - -import { EnvVarListEditor } from '@/app/components/env/env-var-list-editor'; -import { useEnvEditorController } from '@/app/components/env/use-env-editor-controller'; -import { useRegisterActiveEditor } from '@/app/components/ui/editor'; -import { SettingsPage } from '@/app/features/settings/components/settings-page'; -import { SettingsSection } from '@/app/features/settings/components/settings-section'; -import { useOrganizationId } from '@/app/hooks/use-organization-id'; -import { useT } from '@/lib/i18n/client'; - -import { useDeleteMyEnvVar, useUpsertMyEnvVar } from '../hooks/mutations'; -import { useMyEnv } from '../hooks/queries'; - -export function UserEnvSettings() { - const organizationId = useOrganizationId(); - if (!organizationId) return null; - return ; -} - -function UserEnvSettingsInner({ organizationId }: { organizationId: string }) { - const { t } = useT('userEnv'); - const vars = useMyEnv(organizationId); - const { mutateAsync: upsert } = useUpsertMyEnvVar(); - const { mutateAsync: deleteVar } = useDeleteMyEnvVar(); - - const { controller, onEditorState } = useEnvEditorController(); - useRegisterActiveEditor(controller); - - return ( - - - - {t('page.description')} {t('page.note')} - - } - > - { - await upsert({ organizationId, key, value, isSecret }); - }} - onDelete={async (key) => { - await deleteVar({ organizationId, key }); - }} - /> - - - - ); -} diff --git a/services/platform/app/features/settings/user-env/hooks/mutations.ts b/services/platform/app/features/settings/user-env/hooks/mutations.ts deleted file mode 100644 index 88b28b4332..0000000000 --- a/services/platform/app/features/settings/user-env/hooks/mutations.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useBackendAction } from '@/app/hooks/use-backend-action'; -import { useBackendMutation } from '@/app/hooks/use-backend-mutation'; - -/** - * Upsert one of the calling user's env/secret rows. This is a Node `action` - * (it authenticates, validates, and encrypts secrets before persisting) and - * throws a `AppError` with `{ code, message }` on invalid input — callers - * surface `message` inline. - */ -export function useUpsertMyEnvVar() { - return useBackendAction('sandbox/user_env_actions:upsertMyEnvVar'); -} - -/** Delete one of the calling user's env/secret rows. */ -export function useDeleteMyEnvVar() { - // `errorToast: false` — the section toasts its own (better-copy) failure - // message so a failed delete never lingers silently. - return useBackendMutation('sandbox/user_env:deleteMyEnvVar', { - errorToast: false, - }); -} diff --git a/services/platform/app/features/settings/user-env/hooks/queries.ts b/services/platform/app/features/settings/user-env/hooks/queries.ts deleted file mode 100644 index 84d0c454cc..0000000000 --- a/services/platform/app/features/settings/user-env/hooks/queries.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useBackendQuery } from '@/app/hooks/use-backend-query'; - -/** - * The calling user's sandbox env/secrets for the active org. Secrets are - * write-only: only `maskedValue` comes back for them, never the plaintext. - * `undefined` while loading (the section's skeleton vs empty-state split). - */ -export function useMyEnv(organizationId: string) { - const { data } = useBackendQuery('sandbox/user_env:listMyEnv', { - organizationId, - }); - return data; -} diff --git a/services/platform/app/lib/backend/contract/sandbox.ts b/services/platform/app/lib/backend/contract/sandbox.ts index 563a60dfcc..e488f4c122 100644 --- a/services/platform/app/lib/backend/contract/sandbox.ts +++ b/services/platform/app/lib/backend/contract/sandbox.ts @@ -110,30 +110,4 @@ export interface SandboxContract { }; }>; }; - 'sandbox/user_env:deleteMyEnvVar': { - kind: 'mutation'; - args: { organizationId: string; key: string }; - returns: null; - }; - 'sandbox/user_env:listMyEnv': { - kind: 'query'; - args: { organizationId: string }; - returns: Array<{ - key: string; - isSecret: boolean; - value?: string; - maskedValue?: string; - updatedAt: number; - }>; - }; - 'sandbox/user_env_actions:upsertMyEnvVar': { - kind: 'action'; - args: { - organizationId: string; - key: string; - isSecret: boolean; - value: string; - }; - returns: null; - }; } diff --git a/services/platform/app/lib/backend/settings.ts b/services/platform/app/lib/backend/settings.ts index 91a0d19ddb..496318140c 100644 --- a/services/platform/app/lib/backend/settings.ts +++ b/services/platform/app/lib/backend/settings.ts @@ -26,7 +26,6 @@ type MyPreferencesResult = ReturnsOf<'user_preferences/queries:getMyPreferences'>; type NotificationPrefsResult = ReturnsOf<'collab/preferences:getNotificationPreferences'>; -type MyEnvItem = ItemOf<'sandbox/user_env:listMyEnv'>; type AppPasswordItem = ItemOf<'webdav/app_password_queries:listAppPasswords'>; type CreateAppPasswordResult = ReturnsOf<'webdav/app_password_mutations:createAppPassword'>; @@ -275,17 +274,6 @@ export const settingsReadAdapters: Record = { }), }; }, - 'sandbox/user_env:listMyEnv': (args, ctx) => { - const orgId = orgOf(args, ctx); - if (orgId === undefined) return null; - return { - queryKey: backendKey(orgId, 'sandbox_user_env', 'mine'), - queryFn: () => - backendFetch<{ env: MyEnvItem[] }>('/sandbox/user-env', { - orgId, - }).then((body) => body.env), - }; - }, 'governance/queries:getPolicy': (args, ctx) => { const orgId = orgOf(args, ctx); const policyType = args.policyType; @@ -869,18 +857,6 @@ function invalidateUserPrefs( }); } -function invalidateUserEnv( - client: Parameters>[0], - args: Record, - ctx: AdapterContext, -): void { - const orgId = orgOf(args, ctx); - if (orgId === undefined) return; - void client.invalidateQueries({ - queryKey: backendEntityPrefix(orgId, 'sandbox_user_env'), - }); -} - function invalidateProviderCredentials( client: Parameters>[0], args: Record, @@ -1139,26 +1115,6 @@ export const settingsWriteAdapters: Record = { }, }).then(() => null), }, - 'sandbox/user_env_actions:upsertMyEnvVar': { - run: (args, ctx) => - backendFetch<{ ok: boolean }>('/sandbox/user-env', { - orgId: requireOrg(args, ctx), - body: { - key: stringArg(args, 'key'), - value: typeof args.value === 'string' ? args.value : '', - isSecret: args.isSecret === true, - }, - }).then(() => null), - invalidate: invalidateUserEnv, - }, - 'sandbox/user_env:deleteMyEnvVar': { - run: (args, ctx) => - backendFetch<{ deleted: boolean }>( - `/sandbox/user-env/${encodeURIComponent(stringArg(args, 'key'))}`, - { orgId: requireOrg(args, ctx), method: 'DELETE' }, - ).then(() => null), - invalidate: invalidateUserEnv, - }, 'webdav/app_password_mutations:createAppPassword': { run: (args, ctx) => backendFetch('/webdav/app-passwords', { diff --git a/services/platform/app/routeTree.gen.ts b/services/platform/app/routeTree.gen.ts index 5d891bde4e..fbddb59fa9 100644 --- a/services/platform/app/routeTree.gen.ts +++ b/services/platform/app/routeTree.gen.ts @@ -49,7 +49,6 @@ import { Route as DashboardIdSettingsMembersRouteImport } from './routes/dashboa import { Route as DashboardIdSettingsMcpServersRouteImport } from './routes/dashboard/$id/settings/mcp-servers'; import { Route as DashboardIdSettingsMcpRouteImport } from './routes/dashboard/$id/settings/mcp'; import { Route as DashboardIdSettingsLogsRouteImport } from './routes/dashboard/$id/settings/logs'; -import { Route as DashboardIdSettingsEnvironmentRouteImport } from './routes/dashboard/$id/settings/environment'; import { Route as DashboardIdSettingsEnterpriseSsoRouteImport } from './routes/dashboard/$id/settings/enterprise-sso'; import { Route as DashboardIdSettingsDeploymentRouteImport } from './routes/dashboard/$id/settings/deployment'; import { Route as DashboardIdSettingsDataResidencyRouteImport } from './routes/dashboard/$id/settings/data-residency'; @@ -335,12 +334,6 @@ const DashboardIdSettingsLogsRoute = DashboardIdSettingsLogsRouteImport.update({ path: '/logs', getParentRoute: () => DashboardIdSettingsRoute, } as any); -const DashboardIdSettingsEnvironmentRoute = - DashboardIdSettingsEnvironmentRouteImport.update({ - id: '/environment', - path: '/environment', - getParentRoute: () => DashboardIdSettingsRoute, - } as any); const DashboardIdSettingsEnterpriseSsoRoute = DashboardIdSettingsEnterpriseSsoRouteImport.update({ id: '/enterprise-sso', @@ -803,7 +796,6 @@ export interface FileRoutesByFullPath { '/dashboard/$id/settings/data-residency': typeof DashboardIdSettingsDataResidencyRoute; '/dashboard/$id/settings/deployment': typeof DashboardIdSettingsDeploymentRoute; '/dashboard/$id/settings/enterprise-sso': typeof DashboardIdSettingsEnterpriseSsoRoute; - '/dashboard/$id/settings/environment': typeof DashboardIdSettingsEnvironmentRoute; '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute; '/dashboard/$id/settings/mcp': typeof DashboardIdSettingsMcpRoute; '/dashboard/$id/settings/mcp-servers': typeof DashboardIdSettingsMcpServersRoute; @@ -903,7 +895,6 @@ export interface FileRoutesByTo { '/dashboard/$id/settings/data-residency': typeof DashboardIdSettingsDataResidencyRoute; '/dashboard/$id/settings/deployment': typeof DashboardIdSettingsDeploymentRoute; '/dashboard/$id/settings/enterprise-sso': typeof DashboardIdSettingsEnterpriseSsoRoute; - '/dashboard/$id/settings/environment': typeof DashboardIdSettingsEnvironmentRoute; '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute; '/dashboard/$id/settings/mcp': typeof DashboardIdSettingsMcpRoute; '/dashboard/$id/settings/mcp-servers': typeof DashboardIdSettingsMcpServersRoute; @@ -1014,7 +1005,6 @@ export interface FileRoutesById { '/dashboard/$id/settings/data-residency': typeof DashboardIdSettingsDataResidencyRoute; '/dashboard/$id/settings/deployment': typeof DashboardIdSettingsDeploymentRoute; '/dashboard/$id/settings/enterprise-sso': typeof DashboardIdSettingsEnterpriseSsoRoute; - '/dashboard/$id/settings/environment': typeof DashboardIdSettingsEnvironmentRoute; '/dashboard/$id/settings/logs': typeof DashboardIdSettingsLogsRoute; '/dashboard/$id/settings/mcp': typeof DashboardIdSettingsMcpRoute; '/dashboard/$id/settings/mcp-servers': typeof DashboardIdSettingsMcpServersRoute; @@ -1126,7 +1116,6 @@ export interface FileRouteTypes { | '/dashboard/$id/settings/data-residency' | '/dashboard/$id/settings/deployment' | '/dashboard/$id/settings/enterprise-sso' - | '/dashboard/$id/settings/environment' | '/dashboard/$id/settings/logs' | '/dashboard/$id/settings/mcp' | '/dashboard/$id/settings/mcp-servers' @@ -1226,7 +1215,6 @@ export interface FileRouteTypes { | '/dashboard/$id/settings/data-residency' | '/dashboard/$id/settings/deployment' | '/dashboard/$id/settings/enterprise-sso' - | '/dashboard/$id/settings/environment' | '/dashboard/$id/settings/logs' | '/dashboard/$id/settings/mcp' | '/dashboard/$id/settings/mcp-servers' @@ -1336,7 +1324,6 @@ export interface FileRouteTypes { | '/dashboard/$id/settings/data-residency' | '/dashboard/$id/settings/deployment' | '/dashboard/$id/settings/enterprise-sso' - | '/dashboard/$id/settings/environment' | '/dashboard/$id/settings/logs' | '/dashboard/$id/settings/mcp' | '/dashboard/$id/settings/mcp-servers' @@ -1696,13 +1683,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardIdSettingsLogsRouteImport; parentRoute: typeof DashboardIdSettingsRoute; }; - '/dashboard/$id/settings/environment': { - id: '/dashboard/$id/settings/environment'; - path: '/environment'; - fullPath: '/dashboard/$id/settings/environment'; - preLoaderRoute: typeof DashboardIdSettingsEnvironmentRouteImport; - parentRoute: typeof DashboardIdSettingsRoute; - }; '/dashboard/$id/settings/enterprise-sso': { id: '/dashboard/$id/settings/enterprise-sso'; path: '/enterprise-sso'; @@ -2442,7 +2422,6 @@ interface DashboardIdSettingsRouteChildren { DashboardIdSettingsDataResidencyRoute: typeof DashboardIdSettingsDataResidencyRoute; DashboardIdSettingsDeploymentRoute: typeof DashboardIdSettingsDeploymentRoute; DashboardIdSettingsEnterpriseSsoRoute: typeof DashboardIdSettingsEnterpriseSsoRoute; - DashboardIdSettingsEnvironmentRoute: typeof DashboardIdSettingsEnvironmentRoute; DashboardIdSettingsLogsRoute: typeof DashboardIdSettingsLogsRoute; DashboardIdSettingsMcpRoute: typeof DashboardIdSettingsMcpRoute; DashboardIdSettingsMcpServersRoute: typeof DashboardIdSettingsMcpServersRoute; @@ -2474,7 +2453,6 @@ const DashboardIdSettingsRouteChildren: DashboardIdSettingsRouteChildren = { DashboardIdSettingsDataResidencyRoute: DashboardIdSettingsDataResidencyRoute, DashboardIdSettingsDeploymentRoute: DashboardIdSettingsDeploymentRoute, DashboardIdSettingsEnterpriseSsoRoute: DashboardIdSettingsEnterpriseSsoRoute, - DashboardIdSettingsEnvironmentRoute: DashboardIdSettingsEnvironmentRoute, DashboardIdSettingsLogsRoute: DashboardIdSettingsLogsRoute, DashboardIdSettingsMcpRoute: DashboardIdSettingsMcpRoute, DashboardIdSettingsMcpServersRoute: DashboardIdSettingsMcpServersRoute, diff --git a/services/platform/app/routes/dashboard/$id/settings/environment.tsx b/services/platform/app/routes/dashboard/$id/settings/environment.tsx deleted file mode 100644 index 341176a5e6..0000000000 --- a/services/platform/app/routes/dashboard/$id/settings/environment.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -import { UserEnvSettings } from '@/app/features/settings/user-env/components/user-env-settings'; -import { ensureConvexQuery } from '@/app/lib/loader-preload'; -import { seo } from '@/lib/utils/seo'; - -export const Route = createFileRoute('/dashboard/$id/settings/environment')({ - head: () => ({ - meta: seo('environment'), - }), - // Warm the env list so a warm navigation renders the real rows on first - // paint instead of the skeleton. Best-effort — the component's own loading - // state still renders correctly if this misses. - loader: ({ context, params }) => { - void ensureConvexQuery(context, 'sandbox/user_env:listMyEnv', { - organizationId: params.id, - }).catch(console.warn); - }, - component: EnvironmentPage, -}); - -function EnvironmentPage() { - return ; -} diff --git a/services/platform/backend/core/agent_secrets/constants.ts b/services/platform/backend/core/agent_secrets/constants.ts index 0aea6ca036..d3424cae9c 100644 --- a/services/platform/backend/core/agent_secrets/constants.ts +++ b/services/platform/backend/core/agent_secrets/constants.ts @@ -3,7 +3,7 @@ // imports so it stays trivially unit-testable. /** The name IS the env var name: letters/digits/underscore, not starting with - * a digit — identical to the sandbox user-env rule. */ + * a digit. */ export const AGENT_SECRET_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; export const MAX_AGENT_SECRET_NAME_LEN = 128; /** Generous ceiling — OAuth tokens / PEM-ish secrets can be long. */ @@ -52,7 +52,7 @@ export function validateAgentSecretValue(value: string): Validation { /** * A recognizable, low-leak preview of a secret for the manager UI: the first * and last few characters with a fixed-width masked middle (e.g. `ghp_••••b3f`) - * — the same affordance the connector-credential and user-env UIs use. Reveals + * — the same affordance the connector-credential UI uses. Reveals * a tiny edge slice; for a secret too short to reveal safely it returns * `undefined` (the caller shows a full mask), and the masked middle is a * constant width so the true length never leaks. Pure. diff --git a/services/platform/backend/core/sandbox/user_env_constants.test.ts b/services/platform/backend/core/sandbox/user_env_constants.test.ts deleted file mode 100644 index 13bd2c6880..0000000000 --- a/services/platform/backend/core/sandbox/user_env_constants.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - hasInteriorWhitespace, - maskSecretPreview, - MAX_ENV_KEY_LEN, - MAX_ENV_VALUE_LEN, - SECRET_MASK, - validateEnvKey, - validateEnvValue, -} from './user_env_constants'; - -describe('validateEnvKey', () => { - it('accepts valid env var names', () => { - for (const k of [ - 'FOO', - '_x', - 'API_KEY', - 'a1_b2', - 'CLAUDE_CODE_OAUTH_TOKEN', - ]) { - expect(validateEnvKey(k).ok).toBe(true); - } - }); - - it('rejects empty, digit-leading, hyphen/space, and over-long keys', () => { - expect(validateEnvKey('').ok).toBe(false); - expect(validateEnvKey('1ABC').ok).toBe(false); - expect(validateEnvKey('A-B').ok).toBe(false); - expect(validateEnvKey('A B').ok).toBe(false); - expect(validateEnvKey('a'.repeat(MAX_ENV_KEY_LEN + 1)).ok).toBe(false); - }); -}); - -describe('validateEnvValue', () => { - it('accepts values up to the cap and rejects beyond it', () => { - expect(validateEnvValue('').ok).toBe(true); - expect(validateEnvValue('x'.repeat(MAX_ENV_VALUE_LEN)).ok).toBe(true); - expect(validateEnvValue('x'.repeat(MAX_ENV_VALUE_LEN + 1)).ok).toBe(false); - }); -}); - -describe('SECRET_MASK', () => { - it('is a non-empty fixed mask (never the plaintext)', () => { - expect(SECRET_MASK.length).toBeGreaterThan(0); - }); -}); - -describe('maskSecretPreview', () => { - it('reveals the first/last 3 chars with a fixed-width masked middle', () => { - expect(maskSecretPreview('sk-ant-oat01-abcDEFxyz')).toBe('sk-••••xyz'); - expect(maskSecretPreview('abcDEFGHIJ')).toBe('abc••••HIJ'); - }); - - it('fully masks a secret too short to reveal safely (≥4 stay hidden)', () => { - expect(maskSecretPreview('short')).toBe(SECRET_MASK); - expect(maskSecretPreview('abcdefghi')).toBe(SECRET_MASK); // len 9 → <10 - expect(maskSecretPreview('')).toBe(SECRET_MASK); - }); - - it('never leaks the true length (middle mask is constant width)', () => { - const a = maskSecretPreview('abcWWWWWWWWWWWWWWWWWWWWxyz'); - const b = maskSecretPreview('abcWWxyz0000000'); - expect(a).toBe('abc••••xyz'); - expect(b.startsWith('abc••••')).toBe(true); - }); -}); - -describe('hasInteriorWhitespace', () => { - it('is false for clean single-line values (leading/trailing space ignored)', () => { - expect(hasInteriorWhitespace('sk-ant-oat01-abcDEF_123')).toBe(false); - expect(hasInteriorWhitespace(' sk-ant-oat01-abc ')).toBe(false); - expect(hasInteriorWhitespace('')).toBe(false); - }); - - it('is true for an interior space, tab, or newline (wrapped-paste artifact)', () => { - expect(hasInteriorWhitespace('sk-ant-oat01-abc def')).toBe(true); - expect(hasInteriorWhitespace('sk-ant\toat01')).toBe(true); - // The exact failure mode: token wrapped across two terminal lines. - expect(hasInteriorWhitespace('sk-ant-oat01-PC0Ia\n yW3M')).toBe(true); - }); -}); diff --git a/services/platform/backend/core/sandbox/user_env_constants.ts b/services/platform/backend/core/sandbox/user_env_constants.ts deleted file mode 100644 index 13378a64ed..0000000000 --- a/services/platform/backend/core/sandbox/user_env_constants.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Pure constants + validators for user-level sandbox env/secrets. Shared by the -// V8 mutations/queries, the Node upsert action, and unit tests. No Convex or -// Node imports so it stays trivially unit-testable. - -/** Env var name: letters/digits/underscore, not starting with a digit. */ -export const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; -export const MAX_ENV_KEY_LEN = 128; -/** Generous ceiling — OAuth tokens / PEM-ish secrets can be long. */ -export const MAX_ENV_VALUE_LEN = 8192; -/** Per-user guardrail on the number of env/secret entries. */ -export const MAX_ENV_VARS_PER_USER = 100; - -export type Validation = { ok: true } | { ok: false; reason: string }; - -export function validateEnvKey(key: string): Validation { - if (key.length === 0) return { ok: false, reason: 'Key must not be empty.' }; - if (key.length > MAX_ENV_KEY_LEN) { - return { ok: false, reason: `Key exceeds ${MAX_ENV_KEY_LEN} characters.` }; - } - if (!ENV_KEY_RE.test(key)) { - return { - ok: false, - reason: - 'Key must match ^[A-Za-z_][A-Za-z0-9_]*$ (letters, digits, underscore; not starting with a digit).', - }; - } - return { ok: true }; -} - -export function validateEnvValue(value: string): Validation { - if (value.length > MAX_ENV_VALUE_LEN) { - return { - ok: false, - reason: `Value exceeds ${MAX_ENV_VALUE_LEN} characters.`, - }; - } - return { ok: true }; -} - -/** Fixed mask shown for secrets in the read API (plaintext is never exposed). */ -export const SECRET_MASK = '••••••••'; - -/** - * A recognizable, low-leak preview of a secret for the editor: the first and - * last few characters with a fixed-width masked middle (e.g. `sk-••••xyz`) — the - * same affordance API-key UIs use so an operator can tell *which* secret is set - * without it being readable. Deliberately reveals a tiny edge slice; for a - * secret too short to reveal safely it falls back to the full mask, and the - * masked middle is a constant width so the true length never leaks. Pure. - */ -export function maskSecretPreview(plaintext: string): string { - const FIRST = 3; - const LAST = 3; - // Reveal edges only when at least 4 characters stay hidden. - if (plaintext.length < FIRST + LAST + 4) return SECRET_MASK; - return `${plaintext.slice(0, FIRST)}••••${plaintext.slice(-LAST)}`; -} - -/** - * True when the value contains whitespace AFTER trimming its ends — i.e. an - * interior space, tab, or line break. Credentials (tokens / API keys) never - * contain these; the usual cause is pasting a token that wrapped across lines - * in a terminal (a silent, painful-to-debug corruption → 401). The editor warns - * on this. It does NOT block, because legitimately multi-line secrets (PEM - * keys, etc.) contain interior newlines. - */ -export function hasInteriorWhitespace(value: string): boolean { - return /\s/.test(value.trim()); -} diff --git a/services/platform/backend/db/migrations/0050_sandbox_user_env.sql b/services/platform/backend/db/migrations/0050_sandbox_user_env.sql index 8756f1a049..9953d2a425 100644 --- a/services/platform/backend/db/migrations/0050_sandbox_user_env.sql +++ b/services/platform/backend/db/migrations/0050_sandbox_user_env.sql @@ -2,6 +2,11 @@ -- `sandboxUserEnv` table). One row per (org, user, key); secrets are -- encrypted at rest (the shared secret_box envelope) and write-only — -- the read API answers a fixed mask, never plaintext. +-- +-- DEPRECATED (2026-09): the personal Environment page and its +-- `/api/app/sandbox/user-env` routes were retired — nothing ever injected +-- these rows into a sandbox, so the UI promised what no code did. The table +-- stays (applied schema is never dropped); no code reads or writes it. CREATE TABLE app.sandbox_user_env ( id text PRIMARY KEY DEFAULT gen_random_uuid(), org_id text NOT NULL, diff --git a/services/platform/backend/domains/sandbox/routes.ts b/services/platform/backend/domains/sandbox/routes.ts index 671a222501..d263f0e305 100644 --- a/services/platform/backend/domains/sandbox/routes.ts +++ b/services/platform/backend/domains/sandbox/routes.ts @@ -1,4 +1,3 @@ -import { transactSerializable } from '@tale/shared/db/serializable'; import { Hono, type Context } from 'hono'; import type { Sql } from 'postgres'; import { z } from 'zod'; @@ -23,13 +22,6 @@ import { listSandboxViewsForOrg, listSessionsForOrg, } from './sessions.ts'; -import { - deleteMyEnvVar, - listMyEnv, - upsertMyEnvVar, - UserEnvError, -} from './user-env.ts'; - /** * /api/app/sandbox — the sandbox-management surface: the org's live * sessions (with their running ops), always-on pinning, and explicit @@ -54,46 +46,6 @@ export function createSandboxRoutes(deps: { const app = new Hono(); app.use(requireSession(deps.auth), requireOrgMember(deps.sql)); - const envScope = (c: Context) => ({ - organizationId: c.get('orgId'), - userId: c.get('sessionBundle').user.id, - }); - - // User-level env/secrets (always self-scoped; secrets write-only). - app.get('/user-env', async (c) => { - return c.json({ env: await listMyEnv(deps.sql, envScope(c)) }); - }); - - app.post('/user-env', async (c) => { - const body = z - .object({ - key: z.string().min(1).max(128), - value: z.string().max(8192), - isSecret: z.boolean(), - }) - .safeParse(await c.req.json()); - if (!body.success) { - return c.json({ error: 'invalid body' }, 400); - } - try { - await transactSerializable(deps.sql, (tx) => - upsertMyEnvVar(tx, envScope(c), body.data), - ); - return c.json({ ok: true }); - } catch (error) { - if (error instanceof UserEnvError) { - return c.json({ error: error.code, message: error.message }, 400); - } - throw error; - } - }); - - app.delete('/user-env/:key', async (c) => { - return c.json( - await deleteMyEnvVar(deps.sql, envScope(c), c.req.param('key')), - ); - }); - /** Per-budget quota pressure (the 0.4 `getSandboxQuotaUsage` wire). */ app.get('/quota-usage', async (c) => { const denied = requireAdmin(c); diff --git a/services/platform/backend/domains/sandbox/user-env.ts b/services/platform/backend/domains/sandbox/user-env.ts deleted file mode 100644 index 3783aec788..0000000000 --- a/services/platform/backend/domains/sandbox/user-env.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { Sql, TransactionSql } from 'postgres'; - -import { - decryptSecret, - encryptSecret, - type EncryptedSecret, -} from '../../core/lib/secret_box.ts'; -import { - MAX_ENV_VARS_PER_USER, - SECRET_MASK, - validateEnvKey, - validateEnvValue, -} from '../../core/sandbox/user_env_constants.ts'; -import { toJson } from '../../db/sql.ts'; - -/** - * User-level sandbox env/secrets (the 0.4 `sandboxUserEnv` port): one row - * per (org, user, key), auto-attachable to the user's sandbox sessions. - * Secrets are write-only — the settings read answers a fixed mask; the - * injection read (`resolveUserEnvForInjection`) decrypts server-side. - */ - -export class UserEnvError extends Error { - readonly code: string; - - constructor(code: string, message: string) { - super(message); - this.name = 'UserEnvError'; - this.code = code; - } -} - -interface UserEnvRow { - key: string; - isSecret: boolean; - value: string | null; - encrypted: EncryptedSecret | null; - updatedAt: number; - updatedBy: string; -} - -const ENV_COLUMNS = ` - key, is_secret AS "isSecret", value, encrypted, - updated_at_ms::float8 AS "updatedAt", updated_by AS "updatedBy" -`; - -export interface UserEnvListItem { - key: string; - isSecret: boolean; - value?: string; - maskedValue?: string; - updatedAt: number; -} - -/** The settings listing — plaintext for plain vars, a mask for secrets. */ -export async function listMyEnv( - sql: Sql, - scope: { organizationId: string; userId: string }, -): Promise { - const rows = await sql` - SELECT ${sql.unsafe(ENV_COLUMNS)} FROM app.sandbox_user_env - WHERE org_id = ${scope.organizationId} AND user_id = ${scope.userId} - ORDER BY key ASC - `; - return rows.map((row) => { - const item: UserEnvListItem = { - key: row.key, - isSecret: row.isSecret, - updatedAt: row.updatedAt, - }; - if (row.isSecret) { - item.maskedValue = SECRET_MASK; - } else { - item.value = row.value ?? ''; - } - return item; - }); -} - -/** Upsert one env/secret (validation + the per-user cap; secrets encrypted - * with the shared envelope; a secret↔plain flip never leaves stale data). */ -export async function upsertMyEnvVar( - tx: TransactionSql, - scope: { organizationId: string; userId: string }, - args: { key: string; value: string; isSecret: boolean }, -): Promise { - const keyCheck = validateEnvKey(args.key); - if (!keyCheck.ok) { - throw new UserEnvError('invalid', keyCheck.reason); - } - const valueCheck = validateEnvValue(args.value); - if (!valueCheck.ok) { - throw new UserEnvError('invalid', valueCheck.reason); - } - const existing = await tx<{ id: string }[]>` - SELECT id FROM app.sandbox_user_env - WHERE org_id = ${scope.organizationId} AND user_id = ${scope.userId} - AND key = ${args.key} - LIMIT 1 - `; - if (!existing[0]) { - const counts = await tx<{ count: string }[]>` - SELECT count(*)::text AS count FROM app.sandbox_user_env - WHERE org_id = ${scope.organizationId} AND user_id = ${scope.userId} - `; - if (Number(counts[0]?.count ?? '0') >= MAX_ENV_VARS_PER_USER) { - throw new UserEnvError( - 'too_many', - `You can store at most ${MAX_ENV_VARS_PER_USER} environment variables.`, - ); - } - } - const now = Date.now(); - const encrypted = args.isSecret ? encryptSecret(args.value) : null; - const value = args.isSecret ? null : args.value; - await tx` - INSERT INTO app.sandbox_user_env ( - org_id, user_id, key, is_secret, value, encrypted, updated_by, - created_at_ms, updated_at_ms - ) VALUES ( - ${scope.organizationId}, ${scope.userId}, ${args.key}, - ${args.isSecret}, ${value}, - ${encrypted === null ? null : tx.json(toJson(encrypted))}, - ${scope.userId}, ${now}, ${now} - ) - ON CONFLICT (org_id, user_id, key) DO UPDATE SET - is_secret = EXCLUDED.is_secret, value = EXCLUDED.value, - encrypted = EXCLUDED.encrypted, updated_by = EXCLUDED.updated_by, - updated_at_ms = EXCLUDED.updated_at_ms - `; -} - -export async function deleteMyEnvVar( - sql: Sql, - scope: { organizationId: string; userId: string }, - key: string, -): Promise<{ deleted: boolean }> { - const deleted = await sql<{ id: string }[]>` - DELETE FROM app.sandbox_user_env - WHERE org_id = ${scope.organizationId} AND user_id = ${scope.userId} - AND key = ${key} - RETURNING id - `; - return { deleted: deleted.length > 0 }; -} - -/** Injection map for a turn (secrets decrypted server-side; a corrupt - * secret skips rather than aborting the turn — the 0.4 resilience). */ -export async function resolveUserEnvForInjection( - sql: Sql, - scope: { organizationId: string; userId: string }, -): Promise> { - const rows = await sql` - SELECT ${sql.unsafe(ENV_COLUMNS)} FROM app.sandbox_user_env - WHERE org_id = ${scope.organizationId} AND user_id = ${scope.userId} - `; - const env: Record = {}; - for (const row of rows) { - if (row.isSecret) { - if (row.encrypted === null) continue; - try { - env[row.key] = decryptSecret(row.encrypted); - } catch (error) { - console.warn( - `[sandbox.userenv] secret '${row.key}' failed to decrypt:`, - error instanceof Error ? error.message : String(error), - ); - } - } else { - env[row.key] = row.value ?? ''; - } - } - return env; -} diff --git a/services/platform/backend/integration-check.ts b/services/platform/backend/integration-check.ts index 72a2abec03..6b1e7e63b9 100644 --- a/services/platform/backend/integration-check.ts +++ b/services/platform/backend/integration-check.ts @@ -845,62 +845,6 @@ async function checkIdentityDomains( `set → ${setPrefs.status}, read=${prefs.success ? JSON.stringify(prefs.data.preferences?.customInstructions) : 'ERR'}`, ); - // Sandbox user-env (inc 88): plain + secret upsert, masked listing, - // key validation, delete. - const envPlain = await post(`/api/app/sandbox/user-env?orgId=${orgId}`, { - key: 'MY_REGION', - value: 'eu-central-1', - isSecret: false, - }); - const envSecret = await post(`/api/app/sandbox/user-env?orgId=${orgId}`, { - key: 'MY_TOKEN', - value: 'tok-super-secret', - isSecret: true, - }); - const envBadKey = await post(`/api/app/sandbox/user-env?orgId=${orgId}`, { - key: '9bad', - value: 'x', - isSecret: false, - }); - const envList = z - .object({ - env: z.array( - z.object({ - key: z.string(), - isSecret: z.boolean(), - value: z.string().optional(), - maskedValue: z.string().optional(), - }), - ), - }) - .safeParse(await get(`/api/app/sandbox/user-env?orgId=${orgId}`)); - const plainRow = envList.success - ? envList.data.env.find((row) => row.key === 'MY_REGION') - : undefined; - const secretRow = envList.success - ? envList.data.env.find((row) => row.key === 'MY_TOKEN') - : undefined; - const envDelete = await fetch( - `${base}/api/app/sandbox/user-env/MY_REGION?orgId=${orgId}`, - { method: 'DELETE', headers: { cookie, origin: base } }, - ); - const envAfterDelete = z - .object({ env: z.array(z.object({ key: z.string() })) }) - .safeParse(await get(`/api/app/sandbox/user-env?orgId=${orgId}`)); - record( - 'sandbox user-env CRUD (masked secrets, key validation)', - envPlain.ok && - envSecret.ok && - envBadKey.status === 400 && - plainRow?.value === 'eu-central-1' && - secretRow?.maskedValue !== undefined && - secretRow.value === undefined && - envDelete.ok && - envAfterDelete.success && - !envAfterDelete.data.env.some((row) => row.key === 'MY_REGION'), - `plain → ${envPlain.status}, secret → ${envSecret.status}, badKey → ${envBadKey.status} (want 400), masked=${secretRow?.maskedValue !== undefined && secretRow.value === undefined}, deleted=${envAfterDelete.success ? !envAfterDelete.data.env.some((row) => row.key === 'MY_REGION') : 'ERR'}`, - ); - // Providers/connectors/sandbox settings surfaces (inc 89) — shape-level: // catalogs depend on the deploy's config tree, so counts stay untested. const provCatalogs = z diff --git a/services/platform/messages/de.yml b/services/platform/messages/de.yml index e7348ea0a6..dbbe6eea23 100644 --- a/services/platform/messages/de.yml +++ b/services/platform/messages/de.yml @@ -4223,10 +4223,6 @@ metadata: notificationPreferences: title: Benachrichtigungen description: Wähle, welche Ereignisse dir eine Benachrichtigung schicken. - environment: - title: Umgebungsvariablen & Geheimnisse - description: Umgebungsvariablen und Geheimnisse, die in deine Sandboxes - eingespeist werden. organization: title: Organisation description: deine Organisationseinstellungen verwalten. @@ -4357,7 +4353,6 @@ navigation: governance: Richtlinien metrics: Metriken personalization: Personalisierung - environment: Umgebung account: Konto agents: Agenten mcp: MCP @@ -4536,14 +4531,6 @@ personalization: tooLong: Benutzerdefinierte Anweisungen überschreiten {max} Zeichen. toasts: preferencesUpdated: Einstellungen aktualisiert. -userEnv: - page: - title: Umgebungsvariablen & Geheimnisse - description: Variablen und Geheimnisse, die in alle deine Sandboxes - eingespeist werden. - note: Geheimnisse werden verschlüsselt und sind nur beschreibbar — nach dem - Speichern werden sie nie wieder angezeigt. Diese Variablen werden in alle - deine Sandboxes eingespeist. piiConfigPanel: modeLabel: Modus modeTokenize: Tokenisieren @@ -4965,9 +4952,6 @@ settings: description: Wie Tale sich an dich anpasst — Gedächtnis, Instruktionen und Stimme. notifications: description: Wähle, welche Ereignisse dir eine Benachrichtigung schicken. - environment: - description: Umgebungsvariablen und Geheimnisse, die in deine Sandboxes - eingespeist werden. organization: description: Name des Arbeitsbereichs und Identifikatoren. members: diff --git a/services/platform/messages/en.yml b/services/platform/messages/en.yml index b12be6587f..9388866804 100644 --- a/services/platform/messages/en.yml +++ b/services/platform/messages/en.yml @@ -4076,9 +4076,6 @@ metadata: notificationPreferences: title: Notifications description: Choose which events send you a notification. - environment: - title: Environment variables & secrets - description: Environment variables and secrets injected into your sandboxes. organization: title: Organization description: Manage your organization settings. @@ -4207,7 +4204,6 @@ navigation: governance: Governance metrics: Metrics personalization: Preferences - environment: Environment account: Account agents: Agents mcp: MCP @@ -4379,12 +4375,6 @@ personalization: tooLong: Custom instructions exceed {max} characters. toasts: preferencesUpdated: Preferences updated. -userEnv: - page: - title: Environment variables & secrets - description: Variables and secrets injected into all of your sandboxes. - note: Secrets are encrypted and write-only — once saved they are never shown - again. These variables are injected into all of your sandboxes. piiConfigPanel: modeLabel: Mode modeTokenize: Tokenize @@ -5102,8 +5092,6 @@ settings: description: How Tale adapts to you — memory, instructions, and voice. notifications: description: Choose which events send you a notification. - environment: - description: Environment variables and secrets injected into your sandboxes. organization: description: Workspace name and identifiers. members: diff --git a/services/platform/messages/fr.yml b/services/platform/messages/fr.yml index 452f822a48..2dedd4dc7f 100644 --- a/services/platform/messages/fr.yml +++ b/services/platform/messages/fr.yml @@ -4304,9 +4304,6 @@ metadata: notificationPreferences: title: Notifications description: Choisis les événements qui t'envoient une notification. - environment: - title: Variables d'environnement et secrets - description: Variables d'environnement et secrets injectés dans tes sandboxes. organization: title: Organisation description: Gère les paramètres de ton organisation. @@ -4436,7 +4433,6 @@ navigation: governance: Gouvernance metrics: Métriques personalization: Personnalisation - environment: Environnement account: Compte agents: Agents mcp: MCP @@ -4627,14 +4623,6 @@ personalization: tooLong: Les instructions personnalisées dépassent {max} caractères. toasts: preferencesUpdated: Préférences mises à jour. -userEnv: - page: - title: Variables d'environnement et secrets - description: Variables et secrets injectés dans toutes tes sandboxes. - note: - Les secrets sont chiffrés et en écriture seule — une fois enregistrés, ils - ne sont plus jamais affichés. Ces variables sont injectées dans toutes tes - sandboxes. piiConfigPanel: modeLabel: Mode modeTokenize: Tokeniser @@ -5054,8 +5042,6 @@ settings: description: Comment Tale s'adapte à toi — mémoire, instructions et voix. notifications: description: Choisis les événements qui t'envoient une notification. - environment: - description: Variables d'environnement et secrets injectés dans tes sandboxes. organization: description: Nom de l'espace de travail et identifiants. members: diff --git a/services/platform/tests/docs-screenshots/demo-content.ts b/services/platform/tests/docs-screenshots/demo-content.ts index 13949fc0c6..2cc341b2fb 100644 --- a/services/platform/tests/docs-screenshots/demo-content.ts +++ b/services/platform/tests/docs-screenshots/demo-content.ts @@ -275,29 +275,6 @@ export const DEMO_TEAMS: readonly string[] = [ 'Customer success', ] as const; -interface DemoEnvVar { - readonly key: string; - readonly value: string; - readonly secret: boolean; -} - -/** - * Personal environment (Settings > Environment). Keys must match - * `^[A-Za-z_][A-Za-z0-9_]*$` or the save throws. Secret values are write-only — - * the row re-renders as a mask, so idempotency checks the KEY, never the value. - */ -export const DEMO_ENV_VARS: readonly DemoEnvVar[] = [ - { - key: 'CRM_BASE_URL', - value: 'https://crm.northlight.example/api/v2', - secret: false, - }, - // Keep keys short: the row's key input clips a long name flush against its - // right edge with no ellipsis, which reads as broken in a screenshot. - { key: 'ANALYTICS_ORG', value: 'northlight-prod', secret: false }, - { key: 'CRM_API_TOKEN', value: 'nl_crm_2f8c41d9e7b64a0c', secret: true }, -] as const; - /** REST API keys (Settings > API > REST). Names cap at 32 characters. */ export const DEMO_API_KEYS: readonly string[] = [ 'Production ingest', diff --git a/services/platform/tests/docs-screenshots/seed-demo-org.ts b/services/platform/tests/docs-screenshots/seed-demo-org.ts index a811f22f11..c8d7df2e9b 100644 --- a/services/platform/tests/docs-screenshots/seed-demo-org.ts +++ b/services/platform/tests/docs-screenshots/seed-demo-org.ts @@ -31,7 +31,6 @@ import { DEMO_CUSTOM_INSTRUCTIONS, DEMO_DEPARTING_MEMBER, DEMO_DOCUMENTS, - DEMO_ENV_VARS, DEMO_ERASURE_REQUEST, DEMO_KNOWLEDGE_ENTRIES, DEMO_LEGAL_HOLD_REASON, @@ -894,51 +893,6 @@ async function ensureTeams( } } -/** Personal environment variables and secrets (Settings > Environment). */ -async function ensureEnvVars(page: Page, orgId: string): Promise { - await page.goto(`/dashboard/${orgId}/settings/environment`); - const addButton = page.getByRole('button', { name: t('envEditor.add') }); - await expect(addButton).toBeVisible({ timeout: TIMEOUT.FIRST_PAINT }); - - // Rows are label-less inputs, so identity is the KEY's current value — a - // saved secret re-renders as a mask, so the value can never be the check. - const keyInputs = page.getByPlaceholder(t('envEditor.keyPlaceholder')); - const existingKeys = await keyInputs.evaluateAll((inputs) => - inputs.map((input) => (input as HTMLInputElement).value), - ); - - let added = false; - for (const variable of DEMO_ENV_VARS) { - if (existingKeys.includes(variable.key)) continue; - await addButton.click(); - await page - .getByPlaceholder(t('envEditor.keyPlaceholder')) - .last() - .fill(variable.key); - await page - .getByPlaceholder(t('envEditor.valuePlaceholder')) - .last() - .fill(variable.value); - if (variable.secret) { - await page - .getByRole('checkbox', { name: t('envEditor.secret') }) - .last() - .check(); - } - added = true; - } - if (!added) return; - // The row editor runs in externalSave mode: its Save lives in the settings - // header, and success is reported by that cluster flashing the button label - // to "Saved" (the editor's own toast is suppressed in this mode). Wait for - // the flash — it starts only after the persist resolves. - const save = page.getByRole('button', { name: t('common.actions.save') }); - await save.click(); - await expect( - page.getByRole('button', { name: t('common.actions.saved') }), - ).toBeVisible({ timeout: TIMEOUT.PERSIST }); -} - /** REST API keys (Settings > API > REST). */ async function ensureApiKeys(page: Page, orgId: string): Promise { await page.goto(`/dashboard/${orgId}/settings/api/rest`); @@ -1236,7 +1190,6 @@ export async function seedDemoOrg( ); // The settings surfaces that otherwise screenshot as bare empty states. - await step('environment variables', () => ensureEnvVars(page, orgId)); await step('API keys', () => ensureApiKeys(page, orgId)); await step('WebDAV app-passwords', () => ensureWebdavPasswords(page, orgId)); await step('custom instructions', () => diff --git a/services/platform/tests/manual/SETUP.md b/services/platform/tests/manual/SETUP.md index 05448b2c85..14d39b1a21 100644 --- a/services/platform/tests/manual/SETUP.md +++ b/services/platform/tests/manual/SETUP.md @@ -241,7 +241,6 @@ quick pass; deep coverage lives in the per-area guides. | `/dashboard/{org}/settings/account` | profile + security | | `/dashboard/{org}/settings/personalization` | user preferences (custom instructions, memories) | | `/dashboard/{org}/settings/notifications` | notification preferences | -| `/dashboard/{org}/settings/environment` | env vars & secrets form | | `/dashboard/{org}/settings/organization` | org details | | `/dashboard/{org}/settings/teams` | teams list | | `/dashboard/{org}/settings/members` | members list | diff --git a/services/platform/tests/manual/settings.md b/services/platform/tests/manual/settings.md index 227c588ea2..16d695121e 100644 --- a/services/platform/tests/manual/settings.md +++ b/services/platform/tests/manual/settings.md @@ -1,7 +1,7 @@ # Settings — Manual Test Plan > **Purpose**: Exercise the settings surface along its real rail — **Personal** -> (Account, Preferences, Notifications, Environment), **Organization** +> (Account, Preferences, Notifications), **Organization** > (Organization, Teams, Members, AI providers, Connectors, Skills, Branding, > Sandboxes, Governance, Metrics) and **Advanced** (API: REST / MCP / WebDAV, > Enterprise SSO, Data residency). Governance has its own guide @@ -30,7 +30,6 @@ disclosure rows whose children render indented. | Account | `/dashboard/{org}/settings/account` | | Preferences | `/dashboard/{org}/settings/personalization` | | Notifications | `/dashboard/{org}/settings/notifications` | -| Environment | `/dashboard/{org}/settings/environment` | | Organization | `/dashboard/{org}/settings/organization` | | Teams | `/dashboard/{org}/settings/teams` | | Members | `/dashboard/{org}/settings/members` | @@ -72,7 +71,7 @@ the run. > **Agent note**: pages on the header **Save** / **Discard** cluster > (`common.actions.save` / `common.actions.discard`) — Account profile, -> Preferences custom-instructions text, Organization, Branding, Environment, +> Preferences custom-instructions text, Organization, Branding, > Enterprise SSO, Data residency — flash **Saved** (`common.actions.saved`) > and settle back to a disabled Save; there is no page toast. Everything else > saves through its own dialog, row action, or on-flip toggle. Verify every @@ -98,7 +97,6 @@ the run. | F14–F15 (members) | 🔶 partial | `rbac.spec.ts` (a member cannot see the add-member control; the add flows themselves are manual) | | F1 (rail) | 🔶 component | — (no e2e; `settings-rail.test.tsx`) | | F9 (notification prefs) | 🔶 component | — (no e2e; `notification-preferences-settings.test.tsx`) | -| F10 (environment) | 🔶 component | — (no e2e; `env-var-list-editor.test.tsx` in `app/components/env/`) | | F16–F17 (member dialogs) | 🔶 component | — (no e2e; `member-add-dialog.test.tsx`, `member-row-actions.test.tsx`, `member-table.test.tsx`) | | F33 (MCP endpoint) | 🔶 component | — (no e2e; `mcp-endpoint-section.test.tsx`) | | F35 (Enterprise SSO) | 🔶 component | — (no e2e; `enterprise-sso-form.test.tsx`) | @@ -122,7 +120,6 @@ Legend: ✅ fully automated · 🔶 partially automated / component test only · | F7 | Custom instructions text | With the toggle on, type into the instructions field (`personalization.page.customInstructions.placeholder`) → header **Save** | The field only exists while the toggle is on; saving runs through the header cluster (Saved flash, no toast); the text survives a reload; the field shows a character counter and text over the limit shows the inline error (`personalization.errors.tooLong`) | | F8 | Memories & pending sections | Same page → **Saved memories** (`personalization.page.memories.title`) and **Pending suggestions** (`personalization.page.pending.title`) | With no data both render their empty lines (`personalization.page.memories.empty` / `personalization.page.pending.empty`); with the chat backend down the memories area shows **Chat isn't connected yet** (`chat.backendUnavailable.title`) instead of crashing | | F9 | Notification preferences | `/dashboard/{org}/settings/notifications` → flip e.g. **Task assigned to me** (`notificationPreferences.fields.taskAssigned.label`); inspect **Review requests** (`notificationPreferences.fields.taskReview.label`) | Flips save silently (no Save bar, no success toast — a failure raises `notificationPreferences.saveFailed`); the flipped state persists across reload; the review-requests toggle is locked on with the hint (`notificationPreferences.fields.taskReview.lockedHint`); the **Email delivery** section (`notificationPreferences.deliveryTitle`) carries the **Email me actionable alerts** toggle (`notificationPreferences.fields.actionableEmail.label`) | -| F10 | Environment variables | `/dashboard/{org}/settings/environment` (heading `userEnv.page.title`) → **Add variable** (`envEditor.add`) → fill NAME/value (`envEditor.keyPlaceholder` / `envEditor.valuePlaceholder`), tick **Secret** (`envEditor.secret`) → header **Save**; later **Remove** (`envEditor.remove`) → confirm **Remove variable?** (`envEditor.confirmRemoveTitle`) → **Save** again | After Save + reload the row is listed; a secret's value is write-only and never echoed back (`userEnv.page.note`); removal is staged (the confirm says it applies on save) and after saving + reload the row is gone | | F11 | Theme & language (Manage account) | Click the **Manage account** trigger (top-right user icon, `auth.userButton.manageAccount`) → theme tabs (`auth.userButton.themeSystem` / `…themeLight` / `…themeDark`); **Language** submenu (`auth.userButton.language`) → pick EN / DE / FR | The chosen theme applies immediately (the `html` element gains/loses the `dark` class) and survives reload; the chosen language re-renders the UI strings and survives reload — neither lives on a settings page | | F12 | Organization details | `/dashboard/{org}/settings/organization` → **Organization name** (`settings.organization.organizationName`) → edit → header **Save**; check **Default language (for agents)** (`settings.organization.defaultLocale`) and **Organization ID** (`settings.organization.organizationId`) with its copy button (`settings.organization.copyOrganizationId`) | Saved flash, no toast; the new name survives reload; the locale select persists its pick the same way; the org ID is read-only and its copy button fills the clipboard | | F13 | Org danger zone | Same page → **Danger zone** (`settings.organization.dangerZoneTitle`) → **Delete organization** (`settings.organization.deleteConfirmAction`) — **cancel-only** | The confirm dialog (`settings.organization.deleteDialogTitle`) names the org and warns the deletion is irreversible (`settings.organization.deleteDialogDescription`); cancelling changes nothing | @@ -161,7 +158,6 @@ Legend: ✅ fully automated · 🔶 partially automated / component test only · | B5 | Role gating — developer | Sign in as a **developer** role account | The rail additionally shows Providers, Connectors, Sandboxes, and API (all usable), but none of the `orgSettings` pages (Organization, Teams, Members, Branding, Governance, Metrics, Enterprise SSO, Data residency); a direct hit on `…/settings/branding` shows `accessDenied.branding` | | B6 | Required-name validation | Clear the account **Name** and Save; clear the **Organization name** and Save; create a team with a blank **Team name** | Each is blocked inline — `settings.account.profile.nameRequired`, `settings.organization.nameRequired`, `settings.teams.teamNameRequired`; after a reload the original values are unchanged and no team row was added | | B7 | API key edge cases | Create with a blank **Key name**; after F32's revoke, call a REST endpoint with the revoked key (e.g. `curl -H "Authorization: Bearer "` against the API) | The blank name is blocked inline (`settings.apiKeys.form.nameRequired`), no row appears after reload; the revoked key is rejected (401/403) — it no longer authenticates the REST or MCP endpoint | -| B8 | Env var name validation | `/dashboard/{org}/settings/environment` → add a variable named `1FOO`; add two rows with the same NAME | The invalid name shows `envEditor.badKey` and the duplicate shows `envEditor.dupKey`; the save is blocked until fixed — after reload neither bad row exists | | B9 | WebDAV label bounds | **Generate a new app-password** with an empty/whitespace label; then type past 64 characters | An empty label leaves the dialog unsubmittable (Generate does nothing); the input caps typing at 64 chars (`maxLength`) — the server guard is unreachable from the UI; no app-password is created. (Optional, heavy: creating at the cap → `webdav.create.errorLimit`) | | B10 | SSO form guards | On `/dashboard/{org}/settings/enterprise-sso`: **Test connection** with required fields empty; enter a malformed issuer (no scheme) | The empty test is refused with `settings.enterpriseSso.testMissingFields`; the malformed URL shows the inline validation (`settings.enterpriseSso.validation.url`); with `SITE_URL` / `BETTER_AUTH_SECRET` unset on the server the warning banner renders (`settings.enterpriseSso.deploymentWarning.title`) — env-dependent, note rather than force it | From a632e608a803f1ff84e3dff91192b9a1f8fee034 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Fri, 4 Sep 2026 10:45:15 +0800 Subject: [PATCH 2/3] fix(platform): drop the unenforced web search, code execution, upload flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature_flags policy stored `webSearch`, `codeExecution` and `fileUpload` per rule, the editor rendered a toggle and a table column for each, and `GET /governance/my/feature-flags` echoed them — but no server or client code ever read them. The only enforced field is `maxContextTokens` (the chat turn's context cap), and `inputGuardrailsActive` rides the same wire. Remove the toggles, the columns, the three locale keys (en/de/fr) and the three fields from the resolved flags and the frontend contract; word the section for what it does (cap the context window). The schema keeps the three keys optional and `@deprecated` so policy files written by earlier releases keep parsing, and the editor drops them from a rule on its next save. Guards: the resolver ignores the keys, the dialog offers no toggle, a legacy rule saves without them, and the itest parses the flags wire strictly. --- .../components/feature-flags-editor.test.tsx | 56 +++++- .../components/feature-flags-editor.tsx | 163 +++++++----------- .../app/lib/backend/contract/governance.ts | 3 - .../governance/feature_enforcement.test.ts | 58 +++---- .../core/governance/feature_enforcement.ts | 30 ++-- .../platform/backend/integration-check.ts | 10 +- .../lib/shared/schemas/governance.test.ts | 13 ++ .../platform/lib/shared/schemas/governance.ts | 8 + services/platform/messages/de.yml | 9 +- services/platform/messages/en.yml | 7 +- services/platform/messages/fr.yml | 8 +- 11 files changed, 176 insertions(+), 189 deletions(-) diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx index bb6b472a0f..0dfcdcd651 100644 --- a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx @@ -114,9 +114,6 @@ describe('FeatureFlagsEditor', () => { rules: [ { scope: 'default', - webSearch: true, - codeExecution: false, - fileUpload: true, maxContextTokens: 32768, }, ], @@ -128,7 +125,49 @@ describe('FeatureFlagsEditor', () => { render(); expect(screen.getByText('default')).toBeInTheDocument(); - expect(screen.getByText('\u2718')).toBeInTheDocument(); + expect(screen.getByText(/32,768|32768/)).toBeInTheDocument(); + }); + + // The webSearch / codeExecution / fileUpload toggles were retired — nothing + // ever enforced them. The rule dialog must not offer them, and a rule that + // still carries them from an older policy file must lose them on save. + describe('retired feature toggles', () => { + it('offers no toggle for web search, code execution, or file upload', async () => { + const { user } = render(); + await user.click(screen.getByRole('button', { name: /add rule/i })); + + const dialog = screen.getByRole('dialog'); + expect(dialog.querySelectorAll('[role="switch"]')).toHaveLength(0); + expect(screen.queryByText(/web search/i)).toBeNull(); + expect(screen.queryByText(/code execution/i)).toBeNull(); + expect(screen.queryByText(/file upload/i)).toBeNull(); + }); + + it('drops the deprecated keys from a rule when it is saved', async () => { + setSectionOn([ + { + scope: 'default', + webSearch: false, + codeExecution: false, + fileUpload: true, + maxContextTokens: 32768, + }, + ]); + const { user } = render(); + await user.click(screen.getByRole('button', { name: /edit rule 1/i })); + await user.click(screen.getByRole('button', { name: '8K' })); + await user.click(screen.getByRole('button', { name: /confirm/i })); + + expect(saveMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + policyType: 'feature_flags', + config: { + enabled: true, + rules: [{ scope: 'default', maxContextTokens: 8192 }], + }, + }), + ); + }); }); it('renders add rule button', () => { @@ -196,7 +235,7 @@ describe('FeatureFlagsEditor', () => { // 3 placeholder rows in the body, each with the real column count. const bodyRows = container.querySelectorAll('tbody tr'); expect(bodyRows).toHaveLength(3); - expect(bodyRows[0].querySelectorAll('td')).toHaveLength(7); + expect(bodyRows[0].querySelectorAll('td')).toHaveLength(4); }); }); @@ -237,9 +276,10 @@ describe('FeatureFlagsEditor', () => { expect(saveMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ policyType: 'feature_flags', - config: expect.objectContaining({ - rules: [expect.objectContaining({ maxContextTokens: 8192 })], - }), + config: { + enabled: true, + rules: [{ scope: 'default', maxContextTokens: 8192 }], + }, }), ); }); diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx index 1a6590f42f..f2102d7ea6 100644 --- a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx @@ -68,12 +68,22 @@ const CONTEXT_TOKEN_PRESETS = [ ]; function emptyRule(): FeatureFlagRule { - return { - scope: 'default', - webSearch: true, - codeExecution: true, - fileUpload: true, - }; + return { scope: 'default' }; +} + +/** + * The rule as the editor persists it. The `webSearch` / `codeExecution` / + * `fileUpload` toggles are deprecated — nothing ever enforced them — so a rule + * that still carries them from an older policy file loses them on the next + * save instead of keeping controls that do nothing on disk. + */ +function toPersistedRule(rule: FeatureFlagRule): FeatureFlagRule { + const persisted: FeatureFlagRule = { scope: rule.scope }; + if (rule.scopeId !== undefined) persisted.scopeId = rule.scopeId; + if (rule.maxContextTokens !== undefined) { + persisted.maxContextTokens = rule.maxContextTokens; + } + return persisted; } function parseFeatureFlagsConfig(policy: unknown): FeatureFlagsConfig { @@ -255,71 +265,46 @@ function RuleDialog({ /> )} - - updateDraft({ webSearch: checked })} - disabled={cannotManage} - /> - - updateDraft({ codeExecution: checked }) +
+ + updateDraft({ + maxContextTokens: e.target.value + ? Number(e.target.value) + : undefined, + }) } disabled={cannotManage} + placeholder="e.g. 50000" + min={MIN_MAX_CONTEXT_TOKENS} + errorMessage={errors.maxContextTokens} /> - updateDraft({ fileUpload: checked })} - disabled={cannotManage} - /> - -
- - updateDraft({ - maxContextTokens: e.target.value - ? Number(e.target.value) - : undefined, - }) - } - disabled={cannotManage} - placeholder="e.g. 50000" - min={MIN_MAX_CONTEXT_TOKENS} - errorMessage={errors.maxContextTokens} - /> - - {t('featureFlags.maxContextTokensHint')} - - - {CONTEXT_TOKEN_PRESETS.map((preset) => ( - - ))} - -
- + + {t('featureFlags.maxContextTokensHint')} + + + {CONTEXT_TOKEN_PRESETS.map((preset) => ( + + ))} + +
); @@ -329,7 +314,7 @@ function RuleDialog({ const PLACEHOLDER_ROW_COUNT = 3; /** Column count — single source for the empty-state `colSpan` and the * per-cell placeholder rows so they can never drift from the header. */ -const COLUMN_COUNT = 7; +const COLUMN_COUNT = 4; // ============================================================================= // Single editor — owns data fetching, rules state, dialog state, and save/toast @@ -398,7 +383,10 @@ export function FeatureFlagsEditor({ policyType: 'feature_flags', savedEnabled: savedConfig.enabled, isLoading: loading, - buildConfig: (next) => ({ enabled: next, rules: savedConfig.rules }), + buildConfig: (next) => ({ + enabled: next, + rules: savedConfig.rules.map(toPersistedRule), + }), failureTitle: t('toastSaveFailedTitle'), failureDescription: t('featureFlags.saveFailed'), }); @@ -410,7 +398,7 @@ export function FeatureFlagsEditor({ organizationId, policyType: 'feature_flags', // A rule edit is only reachable while the section is on. - config: { enabled: true, rules: nextRules }, + config: { enabled: true, rules: nextRules.map(toPersistedRule) }, }); toast({ title: t('toastSavedTitle'), @@ -549,15 +537,6 @@ export function FeatureFlagsEditor({ {t('featureFlags.scope')} {t('featureFlags.target')} - - {t('featureFlags.webSearch')} - - - {t('featureFlags.codeExecution')} - - - {t('featureFlags.fileUpload')} - {t('featureFlags.maxContextTokens')} @@ -581,21 +560,6 @@ export function FeatureFlagsEditor({
- - -
- - - - -
- - - - -
- -
@@ -621,15 +585,6 @@ export function FeatureFlagsEditor({ {rule.scope} {resolveTarget(rule)} - - {rule.webSearch === false ? '✘' : '✔'} - - - {rule.codeExecution === false ? '✘' : '✔'} - - - {rule.fileUpload === false ? '✘' : '✔'} - {rule.maxContextTokens != null ? formatNumber(rule.maxContextTokens) diff --git a/services/platform/app/lib/backend/contract/governance.ts b/services/platform/app/lib/backend/contract/governance.ts index e088a78fa5..705bc8faf3 100644 --- a/services/platform/app/lib/backend/contract/governance.ts +++ b/services/platform/app/lib/backend/contract/governance.ts @@ -601,9 +601,6 @@ export interface GovernanceContract { args: { organizationId: string }; returns: { inputGuardrailsActive: boolean; - webSearch: boolean; - codeExecution: boolean; - fileUpload: boolean; maxContextTokens?: number; }; }; diff --git a/services/platform/backend/core/governance/feature_enforcement.test.ts b/services/platform/backend/core/governance/feature_enforcement.test.ts index 3fe67ff8c6..846fb622a2 100644 --- a/services/platform/backend/core/governance/feature_enforcement.test.ts +++ b/services/platform/backend/core/governance/feature_enforcement.test.ts @@ -10,25 +10,20 @@ const { resolveFeatureFlags } = await import('./feature_enforcement'); const mockCtx = {} as never; describe('resolveFeatureFlags', () => { - it('returns defaults when no policy exists', async () => { + it('applies no cap when no policy exists', async () => { mockReadPolicyConfig.mockResolvedValue(null); const result = await resolveFeatureFlags(mockCtx, 'org_1', 'user_1', []); - expect(result).toEqual({ - webSearch: true, - codeExecution: true, - fileUpload: true, - }); + expect(result).toEqual({}); }); - it('returns defaults when policy is disabled', async () => { + it('applies no cap when the policy is disabled', async () => { mockReadPolicyConfig.mockResolvedValue({ enabled: false, rules: [ { scope: 'default', - webSearch: false, maxContextTokens: 8192, }, ], @@ -36,14 +31,10 @@ describe('resolveFeatureFlags', () => { const result = await resolveFeatureFlags(mockCtx, 'org_1', 'user_1', []); - expect(result).toEqual({ - webSearch: true, - codeExecution: true, - fileUpload: true, - }); + expect(result).toEqual({}); }); - it('returns defaults when rules array is empty', async () => { + it('applies no cap when the rules array is empty', async () => { mockReadPolicyConfig.mockResolvedValue({ enabled: true, rules: [], @@ -51,20 +42,15 @@ describe('resolveFeatureFlags', () => { const result = await resolveFeatureFlags(mockCtx, 'org_1', 'user_1', []); - expect(result).toEqual({ - webSearch: true, - codeExecution: true, - fileUpload: true, - }); + expect(result).toEqual({}); }); - it('applies default rule when no specific rule matches', async () => { + it('applies the default rule when no specific rule matches', async () => { mockReadPolicyConfig.mockResolvedValue({ enabled: true, rules: [ { scope: 'default', - webSearch: false, maxContextTokens: 16384, }, ], @@ -72,12 +58,7 @@ describe('resolveFeatureFlags', () => { const result = await resolveFeatureFlags(mockCtx, 'org_1', 'user_1', []); - expect(result).toEqual({ - webSearch: false, - codeExecution: true, - fileUpload: true, - maxContextTokens: 16384, - }); + expect(result).toEqual({ maxContextTokens: 16384 }); }); it('user rule takes priority over team, role, and default', async () => { @@ -143,7 +124,11 @@ describe('resolveFeatureFlags', () => { expect(result.maxContextTokens).toBe(131072); }); - it('partial rule merges with defaults for missing fields', async () => { + // The webSearch / codeExecution / fileUpload toggles were never enforced + // anywhere and are retired; a policy file written by an earlier release may + // still carry them, and they must resolve to nothing — not reappear on the + // wire as controls that do nothing. + it('ignores the deprecated toggles a rule may still carry', async () => { mockReadPolicyConfig.mockResolvedValue({ enabled: true, rules: [ @@ -151,30 +136,27 @@ describe('resolveFeatureFlags', () => { scope: 'user', scopeId: 'user_1', webSearch: false, + codeExecution: false, + fileUpload: false, }, ], }); const result = await resolveFeatureFlags(mockCtx, 'org_1', 'user_1', []); - expect(result).toEqual({ - webSearch: false, - codeExecution: true, - fileUpload: true, - maxContextTokens: undefined, - }); + expect(result).toEqual({}); + expect(result).not.toHaveProperty('webSearch'); + expect(result).not.toHaveProperty('codeExecution'); + expect(result).not.toHaveProperty('fileUpload'); }); - it('resolves maxContextTokens from matching rule', async () => { + it('resolves maxContextTokens from the matching rule', async () => { mockReadPolicyConfig.mockResolvedValue({ enabled: true, rules: [ { scope: 'default', maxContextTokens: 32768, - webSearch: true, - codeExecution: true, - fileUpload: true, }, ], }); diff --git a/services/platform/backend/core/governance/feature_enforcement.ts b/services/platform/backend/core/governance/feature_enforcement.ts index c6f85d97ce..c23cee8a0c 100644 --- a/services/platform/backend/core/governance/feature_enforcement.ts +++ b/services/platform/backend/core/governance/feature_enforcement.ts @@ -5,18 +5,19 @@ import type { import type { QueryCtx } from '../lib/ctx'; import { readPolicyConfig } from './helpers'; +/** + * What the `feature_flags` policy actually controls: the context-window cap + * for a user's chat turns. The `webSearch` / `codeExecution` / `fileUpload` + * toggles older policy files may still carry are deprecated and ignored — + * nothing on the server or the client ever enforced them, so resolving them + * only advertised controls that did nothing. + */ export interface ResolvedFeatureFlags { - webSearch: boolean; - codeExecution: boolean; - fileUpload: boolean; + /** Context-window cap for the user's chat turns; absent = no cap. */ maxContextTokens?: number; } -const DEFAULTS: ResolvedFeatureFlags = { - webSearch: true, - codeExecution: true, - fileUpload: true, -}; +const DEFAULTS: ResolvedFeatureFlags = {}; /** * Find the most specific feature flag rule. @@ -49,10 +50,10 @@ function findApplicableRule( } /** - * Resolve feature flags for a user based on governance policies. + * Resolve the feature-flag policy for a user. * - * Returns which features are enabled/disabled for this user. - * When no policy exists, all features default to enabled. + * When no policy exists, the policy is disabled, or no rule matches, no cap + * applies. */ export async function resolveFeatureFlags( ctx: QueryCtx, @@ -92,10 +93,5 @@ export function evaluateFeatureFlags( return { ...DEFAULTS }; } - return { - webSearch: rule.webSearch ?? DEFAULTS.webSearch, - codeExecution: rule.codeExecution ?? DEFAULTS.codeExecution, - fileUpload: rule.fileUpload ?? DEFAULTS.fileUpload, - maxContextTokens: rule.maxContextTokens, - }; + return { maxContextTokens: rule.maxContextTokens }; } diff --git a/services/platform/backend/integration-check.ts b/services/platform/backend/integration-check.ts index 6b1e7e63b9..424f6176f7 100644 --- a/services/platform/backend/integration-check.ts +++ b/services/platform/backend/integration-check.ts @@ -930,9 +930,17 @@ async function checkIdentityDomains( `/api/app/governance/policies/retention_policy?orgId=${orgId}`, { config: { enabled: false } }, ); + // The flags wire carries only what is enforced: the context cap and the + // composer's guardrail gate. The retired webSearch / codeExecution / + // fileUpload toggles must never reappear here — strict, not loose. const myFlags = z .object({ - flags: z.object({ inputGuardrailsActive: z.boolean() }).loose(), + flags: z + .object({ + inputGuardrailsActive: z.boolean(), + maxContextTokens: z.number().optional(), + }) + .strict(), }) .safeParse( await get(`/api/app/governance/my/feature-flags?orgId=${orgId}`), diff --git a/services/platform/lib/shared/schemas/governance.test.ts b/services/platform/lib/shared/schemas/governance.test.ts index 5a0e0cf10a..c5dbb952f6 100644 --- a/services/platform/lib/shared/schemas/governance.test.ts +++ b/services/platform/lib/shared/schemas/governance.test.ts @@ -55,10 +55,23 @@ describe('featureFlagRuleSchema — maxContextTokens validation', () => { }); it('accepts rule without maxContextTokens (optional)', () => { + const result = featureFlagRuleSchema.safeParse({ + scope: 'user', + scopeId: 'user_1', + }); + expect(result.success).toBe(true); + }); + + // The toggles are deprecated (never enforced, no longer written) but a + // policy file from an earlier release may still carry them — it must keep + // parsing rather than fail the whole governance read. + it('still accepts the deprecated webSearch/codeExecution/fileUpload keys', () => { const result = featureFlagRuleSchema.safeParse({ scope: 'user', scopeId: 'user_1', webSearch: false, + codeExecution: false, + fileUpload: true, }); expect(result.success).toBe(true); }); diff --git a/services/platform/lib/shared/schemas/governance.ts b/services/platform/lib/shared/schemas/governance.ts index 735fd5fcb7..b25df2458a 100644 --- a/services/platform/lib/shared/schemas/governance.ts +++ b/services/platform/lib/shared/schemas/governance.ts @@ -432,8 +432,16 @@ export const MIN_MAX_CONTEXT_TOKENS = 4096; export const featureFlagRuleSchema = z.object({ scope: z.enum(['user', 'team', 'role', 'default']), scopeId: z.string().optional(), + /** + * @deprecated Never enforced — no server or client code ever read it — and + * no longer written by the editor. Accepted only so `feature-flags.yml` + * files written by earlier releases keep parsing; the editor drops it on + * the rule's next save. + */ webSearch: z.boolean().optional(), + /** @deprecated See {@link featureFlagRuleSchema} `webSearch`. */ codeExecution: z.boolean().optional(), + /** @deprecated See {@link featureFlagRuleSchema} `webSearch`. */ fileUpload: z.boolean().optional(), maxContextTokens: z.number().min(MIN_MAX_CONTEXT_TOKENS).optional(), }); diff --git a/services/platform/messages/de.yml b/services/platform/messages/de.yml index dbbe6eea23..d4e5643721 100644 --- a/services/platform/messages/de.yml +++ b/services/platform/messages/de.yml @@ -2989,10 +2989,7 @@ governance: saveFailed: Sandbox-Parallelitätslimits konnten nicht gespeichert werden featureFlags: title: Feature-Steuerung - description: Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren. - webSearch: Websuche - codeExecution: Code-Ausführung - fileUpload: Datei-Upload + description: Begrenze das Kontextfenster für KI-Antworten pro Benutzer, Team oder Rolle. maxContextTokens: Max. Kontext-Tokens maxContextTokensHint: Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt. invalidMaxContextTokens: Max. Kontext-Tokens müssen mindestens {min} betragen. @@ -3007,9 +3004,7 @@ governance: Benutzer. Du kannst sie später jederzeit neu erstellen. removeRuleConfirmAction: Löschen noRulesTitle: Keine Feature-Regeln konfiguriert - noRulesDescription: - Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder - Rolle zu steuern. + noRulesDescription: Füge eine Regel hinzu, um Kontext-Tokens pro Benutzer, Team oder Rolle zu begrenzen. saved: Feature-Steuerung aktualisiert saveFailed: Feature-Steuerung konnte nicht gespeichert werden actions: Aktionen diff --git a/services/platform/messages/en.yml b/services/platform/messages/en.yml index 9388866804..f37f6cc846 100644 --- a/services/platform/messages/en.yml +++ b/services/platform/messages/en.yml @@ -2886,10 +2886,7 @@ governance: saveFailed: Failed to save sandbox concurrency limits featureFlags: title: Feature controls - description: Enable or disable features per user, team, or role. - webSearch: Web search - codeExecution: Code execution - fileUpload: File upload + description: Cap the context window for AI replies per user, team, or role. maxContextTokens: Max context tokens maxContextTokensHint: Maximum number of context tokens for AI responses. Leave @@ -2906,7 +2903,7 @@ governance: users. You can always re-create it later. removeRuleConfirmAction: Delete noRulesTitle: No feature control rules configured - noRulesDescription: Add a rule to manage features per user, team, or role. + noRulesDescription: Add a rule to cap context tokens per user, team, or role. saved: Feature controls updated saveFailed: Failed to save feature controls actions: Actions diff --git a/services/platform/messages/fr.yml b/services/platform/messages/fr.yml index 2dedd4dc7f..5a03de4883 100644 --- a/services/platform/messages/fr.yml +++ b/services/platform/messages/fr.yml @@ -3059,10 +3059,7 @@ governance: saveFailed: Échec de l'enregistrement des limites de simultanéité des sandboxes featureFlags: title: Contrôle des fonctionnalités - description: Active ou désactive des fonctionnalités par utilisateur, équipe ou rôle. - webSearch: Recherche web - codeExecution: Exécution de code - fileUpload: Téléversement de fichiers + description: Plafonne la fenêtre de contexte des réponses IA par utilisateur, équipe ou rôle. maxContextTokens: Tokens de contexte maximum maxContextTokensHint: Nombre maximum de tokens de contexte pour les réponses IA. @@ -3080,8 +3077,7 @@ governance: correspondants. Tu peux la recréer à tout moment. removeRuleConfirmAction: Supprimer noRulesTitle: Aucune règle de contrôle de fonctionnalités configurée - noRulesDescription: Ajoute une règle pour gérer les fonctionnalités par - utilisateur, équipe ou rôle. + noRulesDescription: Ajoute une règle pour plafonner les tokens de contexte par utilisateur, équipe ou rôle. saved: Le contrôle des fonctionnalités a été mis à jour saveFailed: Échec de l'enregistrement du contrôle des fonctionnalités actions: Actions From 87a6c3bb98c85c6d9bbcf6a757767bfb977353ec Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Fri, 4 Sep 2026 10:45:15 +0800 Subject: [PATCH 3/3] docs: drop the Environment hand-off from the Preferences page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The personal Environment page is gone (nothing injected its variables), so the "Where this fits" paragraph no longer points at it — en, de, fr. --- docs/de/platform/member/preferences.md | 2 +- docs/en/platform/member/preferences.md | 2 +- docs/fr/platform/member/preferences.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/de/platform/member/preferences.md b/docs/de/platform/member/preferences.md index 703a8bc328..5eb2e7427e 100644 --- a/docs/de/platform/member/preferences.md +++ b/docs/de/platform/member/preferences.md @@ -41,4 +41,4 @@ Die Zeile **Abmelden** unten im Profilmenü bestätigt mit einem Dialog, bevor s ## Wo das hingehört -Einstellungen sind die Linie zwischen dir und dem Rest der Organisation. Der Org-Admin setzt die Standardwerte — die Passwort-Richtlinie, welche Modelle erlaubt sind, welche Governance für einen Chat gilt — und deine Einstellungen überschreiben sie dort, wo Tale es zulässt. Eine persönliche Seite steht abseits dieses Sets: [Umgebungsvariablen & Geheimnisse](/de/platform/member/environment) hält Variablen und Anmeldedaten, die innerhalb einer einzelnen Organisation auf dich begrenzt sind, statt dir über Organisationen hinweg zu folgen — der Ort für den Provider-Schlüssel, den ein BYO-Agent benutzt. Die nächste Lektüre, die sich lohnt, ist [Mitglieds-Übersicht](/de/platform/member/overview) für die Karte des restlichen Mitglieder-Bereichs, oder [Als App installieren](/de/platform/member/install-as-app), wenn du willst, dass Tale in deinem Dock statt in deinen Browser-Tabs lebt. +Einstellungen sind die Linie zwischen dir und dem Rest der Organisation. Der Org-Admin setzt die Standardwerte — die Passwort-Richtlinie, welche Modelle erlaubt sind, welche Governance für einen Chat gilt — und deine Einstellungen überschreiben sie dort, wo Tale es zulässt. Die nächste Lektüre, die sich lohnt, ist [Mitglieds-Übersicht](/de/platform/member/overview) für die Karte des restlichen Mitglieder-Bereichs, oder [Als App installieren](/de/platform/member/install-as-app), wenn du willst, dass Tale in deinem Dock statt in deinen Browser-Tabs lebt. diff --git a/docs/en/platform/member/preferences.md b/docs/en/platform/member/preferences.md index 14272e9c9e..7f31eb5903 100644 --- a/docs/en/platform/member/preferences.md +++ b/docs/en/platform/member/preferences.md @@ -41,4 +41,4 @@ The **Log out** row at the bottom of the profile menu confirms with a dialog bef ## Where this fits -Preferences are the line between you and the rest of the org. The org Admin sets the defaults — the password policy, which models are allowed, what governance applies to a chat — and your preferences override them where Tale lets them. One personal page sits apart from this set: [Environment variables & secrets](/platform/member/environment) holds variables and credentials scoped to you within a single organisation rather than following you across them — the place to keep the provider key a bring-your-own agent uses. The next read worth queuing is [Member overview](/platform/member/overview) for the map of the rest of the Member surface, or [Install as app](/platform/member/install-as-app) if you want Tale to live in your dock rather than your browser tabs. +Preferences are the line between you and the rest of the org. The org Admin sets the defaults — the password policy, which models are allowed, what governance applies to a chat — and your preferences override them where Tale lets them. The next read worth queuing is [Member overview](/platform/member/overview) for the map of the rest of the Member surface, or [Install as app](/platform/member/install-as-app) if you want Tale to live in your dock rather than your browser tabs. diff --git a/docs/fr/platform/member/preferences.md b/docs/fr/platform/member/preferences.md index 3a83f55eca..de21b16420 100644 --- a/docs/fr/platform/member/preferences.md +++ b/docs/fr/platform/member/preferences.md @@ -41,4 +41,4 @@ La ligne **Se déconnecter** en bas du menu de profil confirme via une boîte de ## Où cela s’inscrit -Les préférences sont la ligne entre toi et le reste de l’org. L’Administrateur de l’org pose les valeurs par défaut — la politique de mot de passe, les modèles autorisés, la gouvernance qui s’applique à un chat — et tes préférences les remplacent là où Tale le permet. Une page personnelle se tient à l’écart de cet ensemble : [Variables d’environnement et secrets](/fr/platform/member/environment) porte des variables et des identifiants cantonnés à toi au sein d’une seule organisation plutôt qu’ils ne te suivent d’une org à l’autre — l’endroit où garder la clé de fournisseur qu’utilise un agent BYO. La lecture suivante à mettre en file est [Vue d’ensemble Membre](/fr/platform/member/overview) pour la carte du reste de la surface Membre, ou [Installer en tant qu’app](/fr/platform/member/install-as-app) si tu veux que Tale vive dans ton dock plutôt que dans tes onglets de navigateur. +Les préférences sont la ligne entre toi et le reste de l’org. L’Administrateur de l’org pose les valeurs par défaut — la politique de mot de passe, les modèles autorisés, la gouvernance qui s’applique à un chat — et tes préférences les remplacent là où Tale le permet. La lecture suivante à mettre en file est [Vue d’ensemble Membre](/fr/platform/member/overview) pour la carte du reste de la surface Membre, ou [Installer en tant qu’app](/fr/platform/member/install-as-app) si tu veux que Tale vive dans ton dock plutôt que dans tes onglets de navigateur.